engramx 2.0.0 → 2.0.2

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/CHANGELOG.md CHANGED
@@ -4,6 +4,125 @@ All notable changes to engram are documented here. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.0.2] — 2026-04-18 — Security hotfix: HTTP server auth & CORS
8
+
9
+ **This is a security release. Upgrade immediately if you run `engram server`
10
+ or `engram ui`.** Credit: [@gabiudrescu](https://github.com/gabiudrescu) for
11
+ responsible disclosure ([#7](https://github.com/NickCirv/engram/issues/7)).
12
+
13
+ ### Security — fixed
14
+
15
+ - **Graph exfiltration + persistent prompt injection via cross-origin browser
16
+ tabs.** The HTTP server previously shipped with `Access-Control-Allow-Origin: *`
17
+ on every response and defaulted to no authentication. A malicious page the
18
+ developer visited could `fetch('http://127.0.0.1:7337/query')` to steal the
19
+ local graph, then `POST /learn` (with `Content-Type: text/plain`, a
20
+ CORS-safelisted content type) to persist `bug:` / `fix:` patterns that the
21
+ v2 Sentinel handlers later re-injected into the user's coding agent on
22
+ SessionStart and on every Edit/Write of the named file. Severity: High —
23
+ confidentiality + persistent indirect prompt injection.
24
+
25
+ **Fix (four stacked defenses):**
26
+ 1. **Fail-closed auth.** Every route except `/health` and `/favicon.ico`
27
+ now requires `Authorization: Bearer <token>` or an HttpOnly
28
+ `engram_token` cookie. A random 64-character token is auto-generated
29
+ on first server start and persisted to `~/.engram/http-server.token`
30
+ with mode `0600`. `ENGRAM_API_TOKEN` env var still overrides.
31
+ 2. **No wildcard CORS.** `Access-Control-Allow-Origin: *` has been removed
32
+ from every response. By default no CORS headers are emitted — the
33
+ dashboard is same-origin. Additional origins opt in via
34
+ `ENGRAM_ALLOWED_ORIGINS=a.com,b.com`.
35
+ 3. **Host + Origin validation** (DNS-rebinding defense). Requests with a
36
+ `Host` header other than `127.0.0.1|localhost|::1` on the bound port
37
+ return 400. Requests with an `Origin` not in the same-origin or env
38
+ allowlist return 403.
39
+ 4. **`Content-Type: application/json` enforced on mutations.** POST / PUT /
40
+ DELETE without `application/json` return 415. This blocks the
41
+ `text/plain` CSRF vector from the PoC and forces CORS preflight for
42
+ any cross-origin writer.
43
+
44
+ - **Timing side-channel on token comparison.** The previous
45
+ `header === \`Bearer ${token}\`` comparison was not constant-time.
46
+ Replaced with a length-first, constant-time `safeEqual()`.
47
+
48
+ ### Added
49
+
50
+ - `src/server/auth.ts` — token management (get-or-create, safeEqual, cookie
51
+ parsing, Host/Origin validators).
52
+ - `tests/server/security.test.ts` — PoC-style tests covering fail-closed
53
+ auth (including empty Bearer / empty cookie guards), env-downgrade
54
+ rejection (token is snapshot at start), cookie auth, wildcard-CORS
55
+ absence, same-origin echo, foreign-origin 403, Host header validation
56
+ (including no-port rejection + case-insensitive hostname), `text/plain`
57
+ rejection on `/learn`, the `/ui?token=` cross-site oracle defence via
58
+ `Sec-Fetch-Site` gating, and the end-to-end exploit chain from #7.
59
+ - `SECURITY.md` at repo root with disclosure policy and scope.
60
+ - `GET /ui?token=<t>` bootstrap path for the browser dashboard. The CLI
61
+ passes the token once; the server exchanges it for an HttpOnly cookie via
62
+ a 302 redirect and strips the token from the URL. Dashboard JS never sees
63
+ the raw token.
64
+
65
+ ### Changed
66
+
67
+ - `createHttpServer(projectRoot, port)` now resolves to `Promise<TokenInfo>`
68
+ (previously `Promise<void>`). The returned object exposes the token source
69
+ (env / file / generated) and the token file path. The CLI uses this to
70
+ print a one-time banner pointing users at `~/.engram/http-server.token`
71
+ when a fresh token is minted.
72
+ - `checkAuth` rewritten as fail-closed, accepts Bearer header OR
73
+ `engram_token` cookie, uses constant-time comparison.
74
+ - Server-Sent Events endpoint (`/api/sse`) no longer emits wildcard CORS and
75
+ inherits the same origin-allowlist behavior as every other route.
76
+
77
+ ### Breaking
78
+
79
+ - **External callers (curl, scripts, CI probes) must now send the token.**
80
+ Fix the one-liner on each caller:
81
+ ```bash
82
+ curl -H "Authorization: Bearer $(cat ~/.engram/http-server.token)" \
83
+ http://127.0.0.1:7337/stats
84
+ ```
85
+ - Requests with `Host: something-else.com` are rejected 400 even if they
86
+ resolve to 127.0.0.1 locally. DNS rebinding defense — intended behavior.
87
+ - Cross-origin requests (`Origin: https://example.com`) are rejected 403
88
+ unless the origin is in `ENGRAM_ALLOWED_ORIGINS`. No legitimate caller
89
+ should be affected.
90
+ - `/ui` navigation from the browser now requires `?token=<t>` on first visit
91
+ (set automatically when you run `engram ui`) or a pre-existing
92
+ `engram_token` cookie.
93
+
94
+ ## [2.0.1] — 2026-04-17 — Windows CI + favicon route
95
+
96
+ Patch release fixing two issues caught immediately after v2.0.0 shipped.
97
+
98
+ ### Fixed
99
+
100
+ - **Windows cross-platform bug in the plugin loader.** `PLUGINS_DIR` was a
101
+ module-load-time constant that baked in `homedir()` at import time. Windows
102
+ uses `USERPROFILE` while Unix uses `HOME`, and a frozen constant meant any
103
+ runtime override (tests, future `--plugins-dir` flag, programmatic use)
104
+ couldn't take effect without a module reload. Windows CI failed on the
105
+ plugin-loader tests because `process.env.HOME` mutation had no effect.
106
+ Fixed by introducing `getPluginsDir()` that resolves on every call, and
107
+ accepting an optional `dir` parameter on `loadPlugins()`,
108
+ `getLoadedPlugins()`, and `ensurePluginsDir()`. The `PLUGINS_DIR` constant
109
+ is retained for back-compat but runtime paths now go through the getter.
110
+ - **`/favicon.ico` returning 404 for clients that ignore `<link rel="icon">`.**
111
+ Added an explicit `GET /favicon.ico` route to the HTTP server that serves
112
+ a 238-byte inline SVG favicon with `Cache-Control: public, max-age=86400`.
113
+ The dashboard HTML still inlines the same favicon via `<link>` so modern
114
+ browsers avoid the request entirely.
115
+
116
+ ### Changed
117
+
118
+ - Test count: 640 → 641 (+1 for the "plugins directory does not exist"
119
+ branch of `loadPlugins()`).
120
+
121
+ ### CI
122
+
123
+ - Verified green on GitHub Actions matrix: Ubuntu + Windows × Node 20 + 22.
124
+ Commit `7c6001c`.
125
+
7
126
  ## [2.0.0] — 2026-04-17 — "Ecosystem"
8
127
 
9
128
  The biggest release since v1.0.0. Completes the v2.0 roadmap Phases 1–4:
@@ -0,0 +1,14 @@
1
+ import {
2
+ getOrCreateToken,
3
+ isHostValid,
4
+ isOriginAllowed,
5
+ parseCookies,
6
+ safeEqual
7
+ } from "./chunk-N6PPKOPK.js";
8
+ export {
9
+ getOrCreateToken,
10
+ isHostValid,
11
+ isOriginAllowed,
12
+ parseCookies,
13
+ safeEqual
14
+ };
@@ -0,0 +1,105 @@
1
+ // src/server/auth.ts
2
+ import { randomBytes } from "crypto";
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ writeFileSync
9
+ } from "fs";
10
+ import { homedir } from "os";
11
+ import { join } from "path";
12
+ var TOKEN_MIN_LEN = 32;
13
+ var TOKEN_BYTES = 32;
14
+ function tokenDir() {
15
+ return join(homedir(), ".engram");
16
+ }
17
+ function tokenPath() {
18
+ return join(tokenDir(), "http-server.token");
19
+ }
20
+ function getOrCreateToken() {
21
+ const envToken = process.env.ENGRAM_API_TOKEN;
22
+ if (envToken && envToken.length >= TOKEN_MIN_LEN) {
23
+ return { token: envToken, source: "env", path: null };
24
+ }
25
+ const path = tokenPath();
26
+ if (existsSync(path)) {
27
+ try {
28
+ const cached = readFileSync(path, "utf8").trim();
29
+ if (cached.length >= TOKEN_MIN_LEN) {
30
+ return { token: cached, source: "file", path };
31
+ }
32
+ } catch {
33
+ }
34
+ }
35
+ const fresh = randomBytes(TOKEN_BYTES).toString("hex");
36
+ const dir = tokenDir();
37
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
38
+ writeFileSync(path, fresh + "\n", { mode: 384 });
39
+ try {
40
+ chmodSync(path, 384);
41
+ } catch {
42
+ }
43
+ return { token: fresh, source: "generated", path };
44
+ }
45
+ function safeEqual(a, b) {
46
+ if (a.length === 0 || b.length === 0) return false;
47
+ if (a.length !== b.length) return false;
48
+ let diff = 0;
49
+ for (let i = 0; i < a.length; i++) {
50
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
51
+ }
52
+ return diff === 0;
53
+ }
54
+ function parseCookies(header) {
55
+ const out = {};
56
+ if (!header || typeof header !== "string") return out;
57
+ for (const pair of header.split(/;\s*/)) {
58
+ const eq = pair.indexOf("=");
59
+ if (eq < 0) continue;
60
+ const key = pair.slice(0, eq).trim();
61
+ const value = pair.slice(eq + 1).trim();
62
+ if (key) out[key] = value;
63
+ }
64
+ return out;
65
+ }
66
+ function isHostValid(hostHeader, port) {
67
+ if (!hostHeader) return false;
68
+ let hostname;
69
+ let portStr;
70
+ if (hostHeader.startsWith("[")) {
71
+ const close = hostHeader.indexOf("]");
72
+ if (close < 0) return false;
73
+ hostname = hostHeader.slice(1, close);
74
+ portStr = hostHeader.slice(close + 2);
75
+ } else {
76
+ const colon = hostHeader.lastIndexOf(":");
77
+ if (colon < 0) {
78
+ hostname = hostHeader;
79
+ portStr = "";
80
+ } else {
81
+ hostname = hostHeader.slice(0, colon);
82
+ portStr = hostHeader.slice(colon + 1);
83
+ }
84
+ }
85
+ const h = hostname.toLowerCase();
86
+ if (h !== "127.0.0.1" && h !== "localhost" && h !== "::1") return false;
87
+ if (portStr !== String(port)) return false;
88
+ return true;
89
+ }
90
+ function isOriginAllowed(origin, port) {
91
+ if (origin === `http://127.0.0.1:${port}`) return true;
92
+ if (origin === `http://localhost:${port}`) return true;
93
+ const env = process.env.ENGRAM_ALLOWED_ORIGINS;
94
+ if (!env) return false;
95
+ const list = env.split(",").map((s) => s.trim()).filter(Boolean);
96
+ return list.includes(origin);
97
+ }
98
+
99
+ export {
100
+ getOrCreateToken,
101
+ safeEqual,
102
+ parseCookies,
103
+ isHostValid,
104
+ isOriginAllowed
105
+ };
package/dist/cli.js CHANGED
@@ -1351,7 +1351,7 @@ var BUILTIN_PROVIDERS = [
1351
1351
  ];
1352
1352
  var BUILTIN_NAMES = new Set(BUILTIN_PROVIDERS.map((p) => p.name));
1353
1353
  async function getAllProviders() {
1354
- const { getLoadedPlugins } = await import("./plugin-loader-FCOMVOX7.js");
1354
+ const { getLoadedPlugins } = await import("./plugin-loader-STTGYIL5.js");
1355
1355
  const { loaded } = await getLoadedPlugins();
1356
1356
  const safePlugins = loaded.filter((p) => !BUILTIN_NAMES.has(p.name));
1357
1357
  return [...BUILTIN_PROVIDERS, ...safePlugins];
@@ -3605,12 +3605,12 @@ program.command("stress-test").description("Run stress tests: memory, concurrenc
3605
3605
  }
3606
3606
  });
3607
3607
  program.command("server").description("Start engram HTTP REST server (binds to 127.0.0.1 only)").option("--http", "Enable HTTP server (default)").option("--port <port>", "HTTP port", "7337").option("-p, --project <path>", "Project directory", ".").action(async (opts) => {
3608
- const { startHttpServer } = await import("./server-VBRTTECZ.js");
3608
+ const { startHttpServer } = await import("./server-KUG7U6SG.js");
3609
3609
  await startHttpServer(pathResolve(opts.project), parseInt(opts.port, 10));
3610
3610
  });
3611
3611
  program.command("ui").description("Open the web dashboard (auto-starts HTTP server if needed)").option("--port <port>", "HTTP port", "7337").option("-p, --project <path>", "Project directory", ".").option("--no-open", "Don't launch browser, just print the URL").action(async (opts) => {
3612
3612
  const port = parseInt(opts.port, 10);
3613
- const url = `http://127.0.0.1:${port}/ui`;
3613
+ const publicUrl = `http://127.0.0.1:${port}/ui`;
3614
3614
  const projectRoot = pathResolve(opts.project);
3615
3615
  const { existsSync: existsSync10, readFileSync: readFileSync6 } = await import("fs");
3616
3616
  const pidPath = join9(projectRoot, ".engram", "http-server.pid");
@@ -3624,9 +3624,9 @@ program.command("ui").description("Open the web dashboard (auto-starts HTTP serv
3624
3624
  }
3625
3625
  }
3626
3626
  if (alreadyRunning) {
3627
- console.log(chalk2.dim(`engram server already running \u2014 opening ${url}`));
3627
+ console.log(chalk2.dim(`engram server already running \u2014 opening ${publicUrl}`));
3628
3628
  } else {
3629
- console.log(chalk2.dim(`Starting engram server on ${url}...`));
3629
+ console.log(chalk2.dim(`Starting engram server on ${publicUrl}...`));
3630
3630
  const { spawn } = await import("child_process");
3631
3631
  const child = spawn(
3632
3632
  process.argv[0],
@@ -3636,15 +3636,19 @@ program.command("ui").description("Open the web dashboard (auto-starts HTTP serv
3636
3636
  child.unref();
3637
3637
  await new Promise((r) => setTimeout(r, 500));
3638
3638
  }
3639
- console.log(chalk2.green(`\u2713 Dashboard: ${url}`));
3639
+ const { getOrCreateToken } = await import("./auth-KB2ZRMS3.js");
3640
+ const { token } = getOrCreateToken();
3641
+ const bootUrl = `${publicUrl}?token=${encodeURIComponent(token)}`;
3642
+ console.log(chalk2.green(`\u2713 Dashboard: ${publicUrl}`));
3640
3643
  if (opts.open !== false) {
3641
3644
  const { platform } = process;
3642
3645
  const opener = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
3643
3646
  try {
3644
3647
  const { execFile: execFile4 } = await import("child_process");
3645
- execFile4(opener, [url], { shell: platform === "win32" }, () => {
3648
+ execFile4(opener, [bootUrl], { shell: platform === "win32" }, () => {
3646
3649
  });
3647
3650
  } catch {
3651
+ console.log(chalk2.dim(` Open manually: ${bootUrl}`));
3648
3652
  }
3649
3653
  }
3650
3654
  });
@@ -3798,13 +3802,14 @@ dbCmd.command("rollback").description("Roll back to an earlier schema version (D
3798
3802
  });
3799
3803
  var pluginCmd = program.command("plugin").description("Manage context provider plugins");
3800
3804
  pluginCmd.command("list").description("List installed provider plugins").action(async () => {
3801
- const { loadPlugins, PLUGINS_DIR, ensurePluginsDir } = await import("./plugin-loader-FCOMVOX7.js");
3802
- ensurePluginsDir();
3803
- const { loaded, failed } = await loadPlugins();
3805
+ const { loadPlugins, getPluginsDir, ensurePluginsDir } = await import("./plugin-loader-STTGYIL5.js");
3806
+ const dir = getPluginsDir();
3807
+ ensurePluginsDir(dir);
3808
+ const { loaded, failed } = await loadPlugins(dir);
3804
3809
  if (loaded.length === 0 && failed.length === 0) {
3805
3810
  console.log(chalk2.dim(`No plugins installed.`));
3806
3811
  console.log(chalk2.dim(`Install with: engram plugin install <file.mjs>`));
3807
- console.log(chalk2.dim(`Plugins directory: ${PLUGINS_DIR}`));
3812
+ console.log(chalk2.dim(`Plugins directory: ${dir}`));
3808
3813
  return;
3809
3814
  }
3810
3815
  if (loaded.length > 0) {
@@ -3830,7 +3835,7 @@ pluginCmd.command("list").description("List installed provider plugins").action(
3830
3835
  pluginCmd.command("install").description("Install a plugin by copying its .mjs file into ~/.engram/plugins/").argument("<file>", "Path to plugin .mjs file").action(async (file) => {
3831
3836
  const { copyFileSync: copyFileSync2, statSync: statSync5 } = await import("fs");
3832
3837
  const { basename: basename6 } = await import("path");
3833
- const { PLUGINS_DIR, ensurePluginsDir, validatePlugin } = await import("./plugin-loader-FCOMVOX7.js");
3838
+ const { getPluginsDir, ensurePluginsDir, validatePlugin } = await import("./plugin-loader-STTGYIL5.js");
3834
3839
  const { pathToFileURL } = await import("url");
3835
3840
  const absPath = pathResolve(file);
3836
3841
  if (!existsSync9(absPath)) {
@@ -3859,18 +3864,20 @@ pluginCmd.command("install").description("Install a plugin by copying its .mjs f
3859
3864
  console.error(chalk2.red(`Failed to load plugin: ${e.message}`));
3860
3865
  process.exit(1);
3861
3866
  }
3862
- ensurePluginsDir();
3867
+ const pluginsDir = getPluginsDir();
3868
+ ensurePluginsDir(pluginsDir);
3863
3869
  const destName = basename6(absPath);
3864
- const destPath = join9(PLUGINS_DIR, destName);
3870
+ const destPath = join9(pluginsDir, destName);
3865
3871
  copyFileSync2(absPath, destPath);
3866
3872
  console.log(chalk2.green(`\u2713 Installed: ${destPath}`));
3867
3873
  });
3868
3874
  pluginCmd.command("remove").description("Remove an installed plugin by filename").argument("<filename>", "Plugin filename (e.g., my-provider.mjs)").action(async (filename) => {
3869
- const { PLUGINS_DIR } = await import("./plugin-loader-FCOMVOX7.js");
3870
- const target = join9(PLUGINS_DIR, filename);
3875
+ const { getPluginsDir } = await import("./plugin-loader-STTGYIL5.js");
3876
+ const pluginsDir = getPluginsDir();
3877
+ const target = join9(pluginsDir, filename);
3871
3878
  if (!existsSync9(target)) {
3872
3879
  console.error(chalk2.red(`No such plugin: ${filename}`));
3873
- console.log(chalk2.dim(`Plugins directory: ${PLUGINS_DIR}`));
3880
+ console.log(chalk2.dim(`Plugins directory: ${pluginsDir}`));
3874
3881
  process.exit(1);
3875
3882
  }
3876
3883
  unlinkSync(target);
@@ -3,10 +3,14 @@ import { existsSync, readdirSync, mkdirSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { homedir } from "os";
5
5
  import { pathToFileURL } from "url";
6
- var PLUGINS_DIR = join(homedir(), ".engram", "plugins");
7
- function ensurePluginsDir() {
8
- if (!existsSync(PLUGINS_DIR)) {
9
- mkdirSync(PLUGINS_DIR, { recursive: true });
6
+ function getPluginsDir() {
7
+ return join(homedir(), ".engram", "plugins");
8
+ }
9
+ var PLUGINS_DIR = getPluginsDir();
10
+ function ensurePluginsDir(dir) {
11
+ const target = dir ?? getPluginsDir();
12
+ if (!existsSync(target)) {
13
+ mkdirSync(target, { recursive: true });
10
14
  }
11
15
  }
12
16
  function validatePlugin(mod) {
@@ -48,20 +52,21 @@ function validatePlugin(mod) {
48
52
  }
49
53
  return { plugin: candidate, reason: "" };
50
54
  }
51
- async function loadPlugins() {
55
+ async function loadPlugins(dir) {
56
+ const pluginsDir = dir ?? getPluginsDir();
52
57
  const loaded = [];
53
58
  const failed = [];
54
- if (!existsSync(PLUGINS_DIR)) {
59
+ if (!existsSync(pluginsDir)) {
55
60
  return { loaded, failed };
56
61
  }
57
62
  let files;
58
63
  try {
59
- files = readdirSync(PLUGINS_DIR).filter((f) => f.endsWith(".mjs") || f.endsWith(".js"));
64
+ files = readdirSync(pluginsDir).filter((f) => f.endsWith(".mjs") || f.endsWith(".js"));
60
65
  } catch {
61
66
  return { loaded, failed };
62
67
  }
63
68
  for (const file of files) {
64
- const fullPath = join(PLUGINS_DIR, file);
69
+ const fullPath = join(pluginsDir, file);
65
70
  try {
66
71
  const mod = await import(pathToFileURL(fullPath).href);
67
72
  const { plugin, reason } = validatePlugin(mod);
@@ -81,9 +86,9 @@ async function loadPlugins() {
81
86
  return { loaded, failed };
82
87
  }
83
88
  var _cache = null;
84
- async function getLoadedPlugins() {
89
+ async function getLoadedPlugins(dir) {
85
90
  if (_cache === null) {
86
- _cache = await loadPlugins();
91
+ _cache = await loadPlugins(dir);
87
92
  }
88
93
  return _cache;
89
94
  }
@@ -95,6 +100,7 @@ export {
95
100
  _resetPluginCache,
96
101
  ensurePluginsDir,
97
102
  getLoadedPlugins,
103
+ getPluginsDir,
98
104
  loadPlugins,
99
105
  validatePlugin
100
106
  };
@@ -2,6 +2,13 @@ import {
2
2
  ContextCache,
3
3
  getContextCache
4
4
  } from "./chunk-CIQQ5Y3S.js";
5
+ import {
6
+ getOrCreateToken,
7
+ isHostValid,
8
+ isOriginAllowed,
9
+ parseCookies,
10
+ safeEqual
11
+ } from "./chunk-N6PPKOPK.js";
5
12
  import {
6
13
  getComponentStatus,
7
14
  summarizeHookLog
@@ -1006,6 +1013,14 @@ var PROVIDERS = [
1006
1013
  "context7",
1007
1014
  "obsidian"
1008
1015
  ];
1016
+ var serverToken = "";
1017
+ var serverPort = 0;
1018
+ function currentToken() {
1019
+ return serverToken;
1020
+ }
1021
+ function authCookie(token) {
1022
+ return `engram_token=${token}; HttpOnly; SameSite=Strict; Path=/`;
1023
+ }
1009
1024
  function parseUrl(req) {
1010
1025
  return new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1011
1026
  }
@@ -1014,23 +1029,42 @@ async function readBody(req) {
1014
1029
  for await (const chunk of req) chunks.push(chunk);
1015
1030
  return Buffer.concat(chunks).toString("utf-8");
1016
1031
  }
1017
- function json(res, status, data) {
1032
+ function corsHeaders(req) {
1033
+ const origin = req.headers.origin;
1034
+ if (!origin || !isOriginAllowed(origin, serverPort)) return {};
1035
+ return {
1036
+ "Access-Control-Allow-Origin": origin,
1037
+ "Access-Control-Allow-Credentials": "true",
1038
+ "Vary": "Origin"
1039
+ };
1040
+ }
1041
+ function json(res, status, data, extraHeaders = {}) {
1018
1042
  res.writeHead(status, {
1019
1043
  "Content-Type": "application/json",
1020
- "Access-Control-Allow-Origin": "*",
1021
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1022
- "Access-Control-Allow-Headers": "Authorization, Content-Type"
1044
+ ...extraHeaders
1023
1045
  });
1024
1046
  res.end(JSON.stringify(data));
1025
1047
  }
1026
1048
  function checkAuth(req, res) {
1027
- const token = process.env.ENGRAM_API_TOKEN;
1028
- if (!token) return true;
1029
- const header = req.headers.authorization ?? "";
1030
- if (header === `Bearer ${token}`) return true;
1049
+ const expected = currentToken();
1050
+ const auth = req.headers.authorization ?? "";
1051
+ if (auth.startsWith("Bearer ")) {
1052
+ const presented = auth.slice(7).trim();
1053
+ if (safeEqual(presented, expected)) return true;
1054
+ }
1055
+ const cookies = parseCookies(req.headers.cookie);
1056
+ if (cookies.engram_token && safeEqual(cookies.engram_token, expected)) {
1057
+ return true;
1058
+ }
1031
1059
  json(res, 401, { error: "Unauthorized" });
1032
1060
  return false;
1033
1061
  }
1062
+ function requireJsonContentType(req, res) {
1063
+ const ct = (req.headers["content-type"] ?? "").toLowerCase();
1064
+ if (ct.startsWith("application/json")) return true;
1065
+ json(res, 415, { error: "Content-Type must be application/json" });
1066
+ return false;
1067
+ }
1034
1068
  function handleHealth(_req, res, startedAt) {
1035
1069
  json(res, 200, {
1036
1070
  ok: true,
@@ -1219,12 +1253,12 @@ async function handleGraphGodNodes(_req, res, projectRoot) {
1219
1253
  }
1220
1254
  var sseClients = /* @__PURE__ */ new Set();
1221
1255
  var hookLogWatcher = null;
1222
- function handleSSE(_req, res, projectRoot) {
1256
+ function handleSSE(req, res, projectRoot) {
1223
1257
  res.writeHead(200, {
1224
1258
  "Content-Type": "text/event-stream",
1225
1259
  "Cache-Control": "no-cache",
1226
1260
  "Connection": "keep-alive",
1227
- "Access-Control-Allow-Origin": "*"
1261
+ ...corsHeaders(req)
1228
1262
  });
1229
1263
  res.write('data: {"type":"connected"}\n\n');
1230
1264
  sseClients.add(res);
@@ -1277,23 +1311,73 @@ function removePid(projectRoot) {
1277
1311
  function createHttpServer(projectRoot, port) {
1278
1312
  return new Promise((resolve, reject) => {
1279
1313
  const startedAt = Date.now();
1314
+ const tokenInfo = getOrCreateToken();
1315
+ serverToken = tokenInfo.token;
1316
+ serverPort = port;
1280
1317
  const server = createServer(async (req, res) => {
1318
+ if (!isHostValid(req.headers.host, port)) {
1319
+ res.writeHead(400);
1320
+ res.end();
1321
+ return;
1322
+ }
1323
+ const origin = req.headers.origin;
1324
+ if (origin && !isOriginAllowed(origin, port)) {
1325
+ res.writeHead(403);
1326
+ res.end();
1327
+ return;
1328
+ }
1329
+ if (origin && isOriginAllowed(origin, port)) {
1330
+ res.setHeader("Access-Control-Allow-Origin", origin);
1331
+ res.setHeader("Access-Control-Allow-Credentials", "true");
1332
+ res.setHeader("Vary", "Origin");
1333
+ }
1281
1334
  if (req.method === "OPTIONS") {
1282
1335
  res.writeHead(204, {
1283
- "Access-Control-Allow-Origin": "*",
1284
1336
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1285
1337
  "Access-Control-Allow-Headers": "Authorization, Content-Type"
1286
1338
  });
1287
1339
  res.end();
1288
1340
  return;
1289
1341
  }
1290
- if (!checkAuth(req, res)) return;
1291
1342
  const url = parseUrl(req);
1292
1343
  const path = url.pathname;
1344
+ if (req.method === "GET" && path === "/health") {
1345
+ handleHealth(req, res, startedAt);
1346
+ return;
1347
+ }
1348
+ if (req.method === "GET" && path === "/favicon.ico") {
1349
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#0a0a0b"/><text x="50" y="62" font-size="56" text-anchor="middle" fill="#10b981" font-family="Menlo,monospace">&#9670;</text></svg>';
1350
+ res.writeHead(200, {
1351
+ "Content-Type": "image/svg+xml",
1352
+ "Cache-Control": "public, max-age=86400"
1353
+ });
1354
+ res.end(svg);
1355
+ return;
1356
+ }
1357
+ if (req.method === "GET" && (path === "/ui" || path === "/ui/")) {
1358
+ const queryToken = url.searchParams.get("token");
1359
+ if (queryToken) {
1360
+ const fetchSite = req.headers["sec-fetch-site"];
1361
+ const siteOk = fetchSite === void 0 || fetchSite === "none" || fetchSite === "same-origin";
1362
+ if (siteOk && safeEqual(queryToken, currentToken())) {
1363
+ res.writeHead(302, {
1364
+ Location: "/ui",
1365
+ "Set-Cookie": authCookie(currentToken()),
1366
+ "Referrer-Policy": "no-referrer",
1367
+ "Cache-Control": "no-store",
1368
+ "X-Content-Type-Options": "nosniff"
1369
+ });
1370
+ res.end();
1371
+ return;
1372
+ }
1373
+ }
1374
+ }
1375
+ if (!checkAuth(req, res)) return;
1376
+ if (req.method === "POST" || req.method === "PUT" || req.method === "DELETE") {
1377
+ if (!requireJsonContentType(req, res)) return;
1378
+ }
1293
1379
  try {
1294
- if (req.method === "GET" && path === "/health") {
1295
- handleHealth(req, res, startedAt);
1296
- } else if (req.method === "GET" && path === "/query") {
1380
+ if (req.method === "GET" && path === "/query") {
1297
1381
  await handleQuery(req, res, projectRoot);
1298
1382
  } else if (req.method === "GET" && path === "/stats") {
1299
1383
  await handleStats(req, res, projectRoot);
@@ -1322,7 +1406,9 @@ function createHttpServer(projectRoot, port) {
1322
1406
  } else if (req.method === "GET" && (path === "/ui" || path === "/ui/")) {
1323
1407
  res.writeHead(200, {
1324
1408
  "Content-Type": "text/html; charset=utf-8",
1325
- "Cache-Control": "no-cache"
1409
+ "Cache-Control": "no-cache",
1410
+ "Set-Cookie": authCookie(currentToken()),
1411
+ "X-Content-Type-Options": "nosniff"
1326
1412
  });
1327
1413
  res.end(buildDashboardHtml());
1328
1414
  } else {
@@ -1344,7 +1430,7 @@ function createHttpServer(projectRoot, port) {
1344
1430
  };
1345
1431
  process.on("SIGINT", cleanup);
1346
1432
  process.on("SIGTERM", cleanup);
1347
- resolve();
1433
+ resolve(tokenInfo);
1348
1434
  });
1349
1435
  });
1350
1436
  }
@@ -1352,11 +1438,26 @@ function createHttpServer(projectRoot, port) {
1352
1438
  // src/server/index.ts
1353
1439
  var DEFAULT_PORT = 7337;
1354
1440
  async function startHttpServer(projectRoot, port = DEFAULT_PORT) {
1355
- await createHttpServer(projectRoot, port);
1356
- process.stdout.write(
1357
- `engram HTTP server listening on http://127.0.0.1:${port}
1441
+ const tokenInfo = await createHttpServer(projectRoot, port);
1442
+ const url = `http://127.0.0.1:${port}`;
1443
+ process.stdout.write(`engram HTTP server listening on ${url}
1444
+ `);
1445
+ if (tokenInfo.source === "env") {
1446
+ process.stderr.write(
1447
+ "engram: auth token from ENGRAM_API_TOKEN env var\n"
1448
+ );
1449
+ } else if (tokenInfo.source === "file") {
1450
+ process.stderr.write(
1451
+ `engram: auth token at ${tokenInfo.path}
1452
+ `
1453
+ );
1454
+ } else {
1455
+ process.stderr.write(
1456
+ `engram: auth token generated at ${tokenInfo.path} (0600)
1457
+ curl -H "Authorization: Bearer $(cat ${tokenInfo.path})" ${url}/stats
1358
1458
  `
1359
- );
1459
+ );
1460
+ }
1360
1461
  }
1361
1462
  export {
1362
1463
  startHttpServer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engramx",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "The context spine for AI coding agents. 8 providers + pluggable context sources, 3-layer memory cache, web dashboard, multi-IDE support (Claude Code, Cursor, Continue, Zed, Aider, Windsurf, Neovim, Emacs). 88.1% measured session-level token savings. Local SQLite, zero native deps, zero cloud.",
5
5
  "repository": {
6
6
  "type": "git",