@aixle/insights 0.1.1 → 0.2.1-staging

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.
Files changed (53) hide show
  1. package/README.md +154 -3
  2. package/dist/auth/credentials.d.ts +7 -1
  3. package/dist/auth/credentials.js +71 -14
  4. package/dist/auth/exchange.d.ts +1 -1
  5. package/dist/auth/exchange.js +1 -1
  6. package/dist/auth/flow.d.ts +10 -1
  7. package/dist/auth/flow.js +38 -5
  8. package/dist/auth/keycloak.d.ts +1 -1
  9. package/dist/auth/keycloak.js +20 -1
  10. package/dist/cli.d.ts +7 -3
  11. package/dist/cli.js +87 -21
  12. package/dist/collect-cursor-payloads.d.ts +4 -3
  13. package/dist/collect-cursor-payloads.js +8 -5
  14. package/dist/cursor-checkpoints.d.ts +2 -2
  15. package/dist/cursor-payload-contract.d.ts +5 -5
  16. package/dist/cursor-payload-contract.js +7 -0
  17. package/dist/cursor-settings.d.ts +9 -4
  18. package/dist/cursor-settings.js +80 -10
  19. package/dist/cursor-store-audit.d.ts +2 -2
  20. package/dist/cursor-store-audit.js +22 -11
  21. package/dist/daily-stats-versions.d.ts +3 -1
  22. package/dist/daily-stats-versions.js +6 -7
  23. package/dist/health.d.ts +3 -1
  24. package/dist/health.js +13 -1
  25. package/dist/hooks/cursor-hooks-mapper.d.ts +3 -3
  26. package/dist/hooks/cursor-hooks-mapper.js +1 -1
  27. package/dist/hooks/cursor-hooks-reader.d.ts +2 -0
  28. package/dist/hooks/cursor-hooks-reader.js +2 -2
  29. package/dist/install/cursor.d.ts +34 -0
  30. package/dist/install/cursor.js +193 -0
  31. package/dist/install/index.d.ts +6 -4
  32. package/dist/install/index.js +6 -1
  33. package/dist/lib/client.d.ts +7 -0
  34. package/dist/lib/client.js +17 -0
  35. package/dist/lib/config.js +7 -2
  36. package/dist/lib/project-resolver.d.ts +5 -4
  37. package/dist/lib/project-resolver.js +20 -8
  38. package/dist/lib/transport-security.d.ts +13 -0
  39. package/dist/lib/transport-security.js +47 -0
  40. package/dist/pricing.d.ts +9 -1
  41. package/dist/pricing.js +39 -8
  42. package/dist/readers/claude.d.ts +54 -6
  43. package/dist/readers/claude.js +158 -6
  44. package/dist/readers/cursor-sqlite.d.ts +23 -0
  45. package/dist/readers/cursor-sqlite.js +68 -0
  46. package/dist/readers/cursor.d.ts +11 -8
  47. package/dist/readers/cursor.js +149 -31
  48. package/dist/server.d.ts +20 -3
  49. package/dist/server.js +101 -67
  50. package/dist/state.js +7 -2
  51. package/dist/sync.d.ts +4 -2
  52. package/dist/sync.js +61 -46
  53. package/package.json +2 -2
package/README.md CHANGED
@@ -2,6 +2,83 @@
2
2
 
3
3
  stdio **MCP server** for AI coding-assistant telemetry. Ingests Claude Code JSONL transcripts and Cursor IDE SQLite telemetry into your organization's ingest API, exposes operator tools on the MCP bridge, and pairs with `aixle-insights init` so teammates can onboard without juggling cron jobs or brittle shell hooks.
4
4
 
5
+ ## Architecture reference
6
+
7
+ For implementation architecture, design decisions, and package direction, see [`ARD.md`](./ARD.md).
8
+
9
+ ## Choosing a version
10
+
11
+ Two channels are published. Pick one deliberately — they are **not** interchangeable.
12
+
13
+ | | Production | Staging (QA) |
14
+ |---|---|---|
15
+ | Install | `npm i -g @aixle/insights` | `npm i -g @aixle/insights@staging` |
16
+ | Version looks like | `0.2.0` | `0.2.1-staging` |
17
+ | Points at | the production API | the staging API |
18
+ | Who should use it | **everyone** | QA validating unreleased work |
19
+ | Stability | released, supported | may change or break without notice |
20
+
21
+ ### Production — use this unless told otherwise
22
+
23
+ ```bash
24
+ # One-shot via npx (recommended — always pulls the current release):
25
+ npx -y @aixle/insights init \
26
+ --host https://insights.aixle.com \
27
+ --keycloak-url https://YOUR-KEYCLOAK/realms/YOUR_REALM
28
+
29
+ # Or global install:
30
+ npm i -g @aixle/insights
31
+ aixle-insights --version # e.g. 0.2.0 (no suffix)
32
+ ```
33
+
34
+ ### Staging — QA only
35
+
36
+ Staging builds carry a `-staging` suffix and live on the `staging` dist-tag. You must ask for
37
+ them explicitly; a plain `npm install` will never give you one.
38
+
39
+ ```bash
40
+ # One-shot via npx:
41
+ npx -y @aixle/insights@staging init \
42
+ --host https://staging.insights.aixle.com \
43
+ --keycloak-url https://YOUR-STAGING-KEYCLOAK/realms/YOUR_REALM
44
+
45
+ # Or global install:
46
+ npm i -g @aixle/insights@staging
47
+ aixle-insights --version # e.g. 0.2.1-staging (note the suffix)
48
+ ```
49
+
50
+ Point a staging build at the **staging** API host. Sending staging telemetry to production
51
+ pollutes production analytics.
52
+
53
+ ### Which one do I have?
54
+
55
+ ```bash
56
+ aixle-insights --version # a -staging suffix means a QA build
57
+ npm view @aixle/insights dist-tags # what each channel currently resolves to
58
+ ```
59
+
60
+ Expected output — `latest` and `staging` move independently:
61
+
62
+ ```
63
+ { latest: '0.2.0', staging: '0.2.1-staging' }
64
+ ```
65
+
66
+ ### Switching back to production
67
+
68
+ ```bash
69
+ npm i -g @aixle/insights@latest
70
+ ```
71
+
72
+ Then re-run `init` against the production host, since credentials and the MCP entry are
73
+ per-host.
74
+
75
+ > **Why `npm install` never surprises you with a staging build:** `-staging` versions are semver
76
+ > prereleases, and no ordinary version range resolves to a prerelease. `*`, `^0.2.0`, `~0.2.0`
77
+ > and `>=0.1.0` all select `0.2.0` even when `0.2.1-staging` exists. Staging builds are
78
+ > reachable only by exact version or the `staging` dist-tag.
79
+
80
+ Maintainers: see [`../RELEASING.md`](../RELEASING.md) for how each channel is cut.
81
+
5
82
  ## Install
6
83
 
7
84
  ```bash
@@ -21,7 +98,7 @@ After `init` succeeds:
21
98
 
22
99
  1. **Restart Claude Code** — it discovers the new MCP server on the next launch.
23
100
  2. Open Claude Code; confirm `/mcp` lists **aixle-insights**.
24
- 3. During a Claude session invoke the **`db90_status`** MCP tool (or `aixle-insights health` from the shell) to see connectivity + last sync metadata.
101
+ 3. During a Claude session invoke the **`aixle_insights_status`** MCP tool (or `aixle-insights health` from the shell) to see connectivity + last sync metadata.
25
102
 
26
103
  ## Multi-org
27
104
 
@@ -36,6 +113,14 @@ npx -y @aixle/insights init \
36
113
 
37
114
  You can also set `DB90_ORGANIZATION_ID=<uuid>` in your shell environment, or pin it via `mcpServers.aixle-insights.env` in `~/.claude.json`. The CLI flag overrides the env var when both are set.
38
115
 
116
+ ## First run and backfill
117
+
118
+ Before you run `init`, the MCP server is a deliberate no-op: it does not read, buffer, or send anything, and it does not create any state files. Nothing is lost during this window — Claude Code's transcripts and Cursor's local telemetry stores are unaffected by whether `@aixle/insights` is watching them.
119
+
120
+ The moment `init` succeeds, this package's per-credential state starts from empty (no watermark, no dedupe checkpoint). That means **the very first sync after `init` treats "everything currently on disk" as new** — Claude transcript JSONL files and Cursor SQLite history alike — and sends all of it, not just activity from that point forward. There is no separate "backfill mode" to opt into; it is simply what an empty watermark means the first time `run --once` or the background sync loop executes.
121
+
122
+ If the MCP has been installed but never connected, `aixle-insights health` (or the `status` MCP tool) reports `needs_init: true` with a human-readable `onboarding_message` explaining exactly this — install-but-uninitialized is not a state you need to worry about losing data in.
123
+
39
124
  ## Commands
40
125
 
41
126
  | Command | What it does |
@@ -44,6 +129,8 @@ You can also set `DB90_ORGANIZATION_ID=<uuid>` in your shell environment, or pin
44
129
  | `aixle-insights run --once` | Perform one multi-tool sync, exit. Useful for cron / manual flushes. |
45
130
  | `aixle-insights run --once --full` | Backfill: ignore Cursor watermarks and commit-hash dedupe. |
46
131
  | `aixle-insights init` | Keycloak device login + persist credentials + merge `~/.claude.json` entry. |
132
+ | `aixle-insights init --host <url>` | Use a DB90 API origin for token exchange. Remote hosts must use HTTPS; `http://localhost` and loopback addresses are allowed for local development. |
133
+ | `aixle-insights init --insecure --host http://...` | Allow a remote plaintext HTTP host for a trusted non-production test endpoint. Prints a warning because tokens and telemetry can be exposed. |
47
134
  | `aixle-insights init --hooks --tool-name cursor` | Also install the Cursor-side hook forwarder (opt-in; requires Cursor restart). |
48
135
  | `aixle-insights uninstall-hooks` | Remove the hook forwarder + restore `~/.cursor/hooks.json` backup. |
49
136
  | `aixle-insights verify-hooks` | Print hooks install status + queue depth as JSON. |
@@ -59,6 +146,8 @@ You can also set `DB90_ORGANIZATION_ID=<uuid>` in your shell environment, or pin
59
146
  | `DB90_ORGANIZATION_ID` | Optional UUID scoping `init` to that org membership (header `X-Organization-ID`). |
60
147
  | `AIXLE_INSIGHTS_HOME` | Override the local state directory (defaults to `~/.aixle-insights/`). |
61
148
 
149
+ Remote API and ingest hosts must use `https://`. Plaintext `http://` is local-dev-only for `localhost`, `127.0.0.0/8`, and `[::1]`; those loopback URLs work without warnings. A remote `http://` host is rejected during `init` unless you pass `--insecure`, which should only be used for trusted non-production test endpoints and will print a warning because ingest tokens and telemetry can cross the network unencrypted.
150
+
62
151
  Note: the `DB90_*` variables above are retained as compatibility names for the deployment-side Keycloak realm/client identifiers. Future versions may rename them with a deprecation window.
63
152
 
64
153
  Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per-model rates):
@@ -73,6 +162,32 @@ Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per
73
162
  }
74
163
  ```
75
164
 
165
+ ## Security
166
+
167
+ `@aixle/insights` enforces HTTPS for any remote host. Plaintext `http://` is allowed only for loopback (`localhost`, `127.0.0.0/8`, `[::1]`) so that local-dev flows against `make up` continue to work without friction.
168
+
169
+ ### Three gates
170
+
171
+ | Gate | Where it fires | What it checks |
172
+ |---|---|---|
173
+ | CLI `--host` gate | `runInit()` at the top of `aixle-insights init`, before any network call to Keycloak | The `--host` value the user typed |
174
+ | Post-exchange `ingestHost` gate | `auth/flow.ts`, immediately after the OIDC-for-ingest-token exchange returns, before persisting credentials to the keychain | The `ingestHost` returned by the server, in case it differs from `--host` |
175
+ | Runtime send/lookup gate | `lib/client.ts`'s `postEvent` and `lib/project-resolver.ts`'s `lookupProjectByRemote`, immediately before every ingest POST and project-attribution GET | The `host` loaded from stored credentials, on **every** sync cycle — not just at `init` |
176
+
177
+ All three gates use the same pure utility, `evaluateTransportSecurity()` in `src/lib/transport-security.ts`. The first two rejecting aborts `init` with exit code 1 and a single-line error naming the offending host. The third rejecting drops that send/lookup (logged via `console.error`, no retry) without aborting the whole sync cycle — a single tampered credential shouldn't crash background sync, it should just refuse to leak the token.
178
+
179
+ The runtime gate exists because `init`'s two gates only run once, at login time. If `~/.aixle-insights/credentials.json` is edited afterward (by hand, by malware, or by disk corruption) to point at a plaintext `http://` remote, nothing previously re-checked the scheme before every subsequent sync sent the bearer token — DB90DV-539 closed that gap.
180
+
181
+ ### `--insecure` (init-only, consent persists to runtime)
182
+
183
+ `aixle-insights init --insecure --host http://<remote>` downgrades the first two gates from "reject" to "warn + continue." It is intended only for trusted non-production test endpoints (e.g. a self-hosted staging on a private network without a TLS cert).
184
+
185
+ `--insecure` is rejected on the `run` subcommand by design — the long-running MCP server should never run insecurely, and since `run` is normally spawned non-interactively (by Claude Code, via `~/.claude.json`), there is no ergonomic way to pass a flag to it per-cycle anyway. Instead, when `init --insecure` is used, that consent is recorded as `insecureHttpAllowed: true` on the stored credential (`StoredCredentials.insecureHttpAllowed` in `auth/credentials.ts`) and every later `run` reads it back — so a legitimately-approved trusted HTTP endpoint keeps syncing normally. A `credentials.json` that has an `http://` remote host **without** this flag set (e.g. because someone hand-edited the file after the fact, bypassing `init` entirely) is rejected by the runtime gate on every send.
186
+
187
+ ### What is NOT gated
188
+
189
+ The Keycloak issuer URL (`--keycloak-url` / `KEYCLOAK_ISSUER`) is **not** TLS-gated by this package. Same threat model, different ticket — tracked separately. For now, use HTTPS for any remote Keycloak issuer; the OIDC device-flow library will fail the request if the cert is invalid, but it will not refuse to attempt plaintext.
190
+
76
191
  ## Cursor hook forwarder (opt-in)
77
192
 
78
193
  `aixle-insights init --hooks --tool-name cursor` installs a Node script as a Cursor hook (`~/.cursor/hooks.json`). The script appends redacted hook payloads to `~/.aixle-insights/hooks-queue.ndjson`; the background sync drains the queue on its next cycle and POSTs the events with accurate per-turn model attribution. Requires a Cursor restart after install. To remove, run `aixle-insights uninstall-hooks` and restart Cursor again.
@@ -80,9 +195,25 @@ Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per
80
195
  ## State + credentials
81
196
 
82
197
  - **App home directory**: `~/.aixle-insights/` (override with `AIXLE_INSIGHTS_HOME`).
83
- - **Credentials**: OS keychain via `keytar` when available (service: `aixle-insights`); fallback file `~/.aixle-insights/credentials.json` with mode 0600 on POSIX.
198
+ - **Credentials**: the OS keychain is the source of truth. On read, the keychain is consulted **first**; the file is only a fallback when the keychain is unavailable, empty, or explicitly disabled (`DB90_MCP_DISABLE_KEYTAR`). On write, credentials go to the keychain and the file is removed when the keychain write succeeds.
84
199
  - **State files**: `state-<hostname>-<token-hash>.json` per credential, plus `state.lock` advisory lock, `mcp.log` rotating diagnostic log, optional `hooks-queue.ndjson`.
85
200
 
201
+ ### Credential storage by OS
202
+
203
+ The secure store is the platform's native keychain, accessed via `keytar` (service `aixle-insights`):
204
+
205
+ | OS | Secure store | Availability | Fallback file protection |
206
+ | --- | --- | --- | --- |
207
+ | **macOS** | Keychain | reliably present | `credentials.json` written `chmod 0600` |
208
+ | **Windows** | Credential Manager | reliably present | `credentials.json` best-effort locked via `icacls` (Node `chmod` cannot set NTFS ACLs) |
209
+ | **Linux** | Secret Service (libsecret / GNOME Keyring / KWallet) | **often absent** on headless servers, minimal Docker images, and CI — no D-Bus secret service | `credentials.json` written `chmod 0600` |
210
+
211
+ Notes:
212
+
213
+ - **Windows**: don't set `DB90_MCP_DISABLE_KEYTAR` — Credential Manager is reliably present, and the plaintext fallback file cannot be locked down as tightly as the keychain. The `icacls` hardening is best-effort defense-in-depth.
214
+ - **Linux**: when no Secret Service is running (common in headless/CI/container contexts), the tool degrades to the `chmod 0600` fallback file **by design** — this is the one environment where the file path is routinely exercised, and POSIX permissions protect it there.
215
+ - A stale `credentials.json` sitting alongside a populated keychain entry is logged as drift (`credentials_file_shadowed_by_keychain` in `mcp.log`) and ignored in favour of the keychain.
216
+
86
217
  The internal state-file shape is implementation-detail; don't depend on it from outside this package.
87
218
 
88
219
  ## Diagnostics
@@ -92,7 +223,21 @@ aixle-insights health # connectivity + last sync metadata
92
223
  aixle-insights verify-hooks # JSON: hooks installed + queue depth
93
224
  ```
94
225
 
95
- `mcp.log` (rotates at 5 MiB to `mcp.log.1`) under the app home directory captures operational events. Inside Claude Code, the **`db90_status`** MCP tool returns the same diagnostic structure as `aixle-insights health`.
226
+ `mcp.log` (rotates at 5 MiB to `mcp.log.1`) under the app home directory captures operational events. Inside Claude Code, the **`aixle_insights_status`** MCP tool returns the same diagnostic structure as `aixle-insights health`.
227
+
228
+ ## Troubleshooting
229
+
230
+ | Symptom | Most likely cause | Fix |
231
+ |---|---|---|
232
+ | `Error: DB90 API host <name> uses remote plaintext HTTP.` | You passed `--host http://<remote>` without `--insecure`. | Use `https://...`, or add `--insecure` if you know the endpoint is trusted and non-production. |
233
+ | `Blocked event send — DB90 ingest host <name> uses remote plaintext HTTP.` (or `Blocked project lookup — ...`) in `mcp.log` / console during `run` | `credentials.json` (or the keychain entry) has an `http://` remote `host` without a recorded `--insecure` consent — most likely because it was edited outside of `init`. | Re-run `aixle-insights init --host https://... ` (or `init --insecure --host http://...` if the endpoint is genuinely trusted non-prod) to re-establish credentials with an explicit, recorded decision. |
234
+ | `Auth failed: fetch failed` during `init` | The `--keycloak-url` host doesn't resolve (NXDOMAIN), is behind a VPN, or the TLS cert is bad. | Verify with `curl -sS https://<host>/realms/<realm>/.well-known/openid-configuration`. For DB90 staging, the canonical Keycloak URL is embedded in the SPA — `curl https://<APP_HOST> \| grep keycloakUrl` extracts the current value. |
235
+ | `Failed to post event: HTTP 401 Unauthorized` repeated for every turn | Your saved ingest token has been rotated, revoked, or invalidated by a server redeploy. The ingest token is distinct from the Keycloak access token that `health` reports as `authenticated: true`. | Reset the keychain entry and re-run `init`: `security delete-generic-password -s "aixle-insights" -a "aixle-insights-ingest-credential"` then `rm -f ~/.aixle-insights/credentials.json` then `aixle-insights init --host ... --keycloak-url ...`. State files are **not** deleted, so already-sent sessions stay deduped. |
236
+ | `health` shows `authenticated: true` but `last_result` is `sent: 0, failed: N` cycle after cycle | Same as the 401 row above. `authenticated` only proves the OIDC token was acquired, not that the ingest token still validates server-side. | Re-init as above. |
237
+ | `last_result` reports `sent: N` but the Events UI shows nothing | The Temporal worker is not running. The ingest endpoint returns HTTP 202 (queued) regardless of worker state. | `make worker` (or check `docker ps` for `db90-worker`). See [LOCAL-DEV.md](./LOCAL-DEV.md) §1. |
238
+ | `sync_lock_skip {reason: "advisory_lock_held"}` in the log | Another sync cycle is still holding `~/.aixle-insights/state.lock`. | Wait for it to finish; only delete the lock file (`rm -f ~/.aixle-insights/state.lock`) after confirming no `aixle-insights run` process is alive (`pgrep -fa aixle-insights`). |
239
+ | `aixle-insights --help` doesn't list `--insecure` | You're running an older published version of the package, not the local source. | `which aixle-insights` shows the path. To run local source: `cd packages/tools/aixle-insights && npm run build && npm link`. To return to the published version: `npm unlink -g @aixle/insights && npm install -g @aixle/insights@latest`. |
240
+ | Not sure whether `aixle-insights` is a `npm link` or a real install | Real installs are regular files; `npm link` is a symlink chain into the repo. | `readlink "$(which aixle-insights)"` shows the link target if any. A linked install will trace back to a path under your monorepo checkout. |
96
241
 
97
242
  ## Local development — `/aixle-reset` skill
98
243
 
@@ -131,6 +276,12 @@ After the script reports success: **quit and reopen Claude Code / Cursor** so ea
131
276
 
132
277
  - Node.js ≥ 20.
133
278
  - macOS / Linux / Windows. On Windows, the package writes a `cmd /c npx …` wrapper in `~/.claude.json` so Claude Code can spawn the MCP server reliably.
279
+ - `better-sqlite3` is a native module. After a Node upgrade, if SQLite reads start failing, rebuild it from the tools workspace:
280
+
281
+ ```bash
282
+ cd packages/tools
283
+ npm rebuild better-sqlite3
284
+ ```
134
285
 
135
286
  ## License
136
287
 
@@ -5,6 +5,12 @@ export interface StoredCredentials {
5
5
  host: string;
6
6
  organizationId?: string;
7
7
  accounts: Partial<Record<TelemetryToolId, string>>;
8
+ /**
9
+ * Set only when the user explicitly passed `init --insecure` for this host.
10
+ * There is no `run --insecure` flag (by design — see README § Security), so
11
+ * runtime sync honors this persisted consent instead of re-prompting.
12
+ */
13
+ insecureHttpAllowed?: boolean;
8
14
  }
9
15
  export declare const KEYTAR_SERVICE = "aixle-insights";
10
16
  /** Returns true when at least one tool has a non-empty token. */
@@ -12,7 +18,7 @@ export declare function credentialsHaveAnyToken(creds: StoredCredentials): boole
12
18
  export declare function pickProjectLookupToken(creds: StoredCredentials): string | null;
13
19
  /** Read credentials from disk only (tests / fallback). */
14
20
  export declare function loadCredentialsFromFileOnly(appDir?: string): StoredCredentials | null;
15
- /** Prefer OS keychain when keytar works; otherwise read `credentials.json`. */
21
+ /** Prefer the OS keychain when keytar works; fall back to `credentials.json`. */
16
22
  export declare function loadCredentials(appDir?: string): Promise<StoredCredentials | null>;
17
23
  /**
18
24
  * Persist multi-tool ingest tokens for one host namespace.
@@ -1,6 +1,9 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { userInfo } from "node:os";
2
4
  import { join } from "node:path";
3
5
  import { getAppDir } from "../state.js";
6
+ import { mcpLog } from "../log.js";
4
7
  export const KEYTAR_SERVICE = "aixle-insights";
5
8
  const KEYTAR_ACCOUNT = "aixle-insights-ingest-credential";
6
9
  function credentialsPath(appDir) {
@@ -45,6 +48,7 @@ function normalizeLoadedCredentials(raw) {
45
48
  host,
46
49
  accounts: out,
47
50
  organizationId: typeof org === "string" ? org : undefined,
51
+ ...(o.insecureHttpAllowed === true ? { insecureHttpAllowed: true } : {}),
48
52
  };
49
53
  }
50
54
  const token = o.token;
@@ -62,22 +66,33 @@ export function loadCredentialsFromFileOnly(appDir = getAppDir()) {
62
66
  const raw = JSON.parse(readFileSync(filePath, "utf-8"));
63
67
  return normalizeLoadedCredentials(raw);
64
68
  }
65
- catch {
69
+ catch (err) {
70
+ // File exists (checked above) but failed to parse/normalize — distinguishes tampering from "never created".
71
+ mcpLog.warn("credentials_parse_failed", { path: filePath, error: err instanceof Error ? err.message : String(err) }, false);
66
72
  return null;
67
73
  }
68
74
  }
69
75
  async function tryKeytarGet() {
70
76
  if (keytarDisabled())
71
77
  return null;
78
+ let raw;
72
79
  try {
73
80
  const keytar = await import("keytar");
74
- const raw = await keytar.default.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
75
- if (!raw)
76
- return null;
81
+ raw = await keytar.default.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
82
+ }
83
+ catch {
84
+ // Keytar unavailable (native module missing/unbuilt, no Secret Service, etc.) — silent fallback to file, same as tryKeytarSet.
85
+ return null;
86
+ }
87
+ if (!raw)
88
+ return null;
89
+ try {
77
90
  const parsed = JSON.parse(raw);
78
91
  return normalizeLoadedCredentials(parsed);
79
92
  }
80
- catch {
93
+ catch (err) {
94
+ // Keychain entry exists (checked above) but failed to parse/normalize — distinguishes tampering from "no entry".
95
+ mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, error: err instanceof Error ? err.message : String(err) }, false);
81
96
  return null;
82
97
  }
83
98
  }
@@ -100,8 +115,32 @@ async function tryKeytarDelete() {
100
115
  const keytar = await import("keytar");
101
116
  await keytar.default.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
102
117
  }
118
+ catch (err) {
119
+ // Non-fatal, but a stale keychain entry can mislead later loads — record it.
120
+ mcpLog.warn("keytar_delete_failed", { keytarService: KEYTAR_SERVICE, error: err instanceof Error ? err.message : String(err) }, false);
121
+ }
122
+ }
123
+ /**
124
+ * Best-effort NTFS ACL lock-down for the fallback credentials file on Windows.
125
+ *
126
+ * Node's file `mode` / `chmodSync` only toggle the read-only bit on Windows — they do NOT
127
+ * map to NTFS ACLs — so the POSIX `0o600` we set on other platforms has no real effect there.
128
+ * We shell out to the built-in `icacls` to drop inherited ACEs and grant only the current user
129
+ * full control. Mirrors the POSIX `chmod` best-effort: any failure is swallowed (the user-profile
130
+ * directory already blocks cross-user reads, and Windows Credential Manager — the preferred store
131
+ * via keytar — makes this fallback file rare in the first place).
132
+ */
133
+ function restrictWindowsAclBestEffort(filePath) {
134
+ try {
135
+ const user = userInfo().username;
136
+ if (!user)
137
+ return;
138
+ execFileSync("icacls", [filePath, "/inheritance:r", "/grant:r", `${user}:F`], {
139
+ stdio: "ignore",
140
+ });
141
+ }
103
142
  catch {
104
- // ignore
143
+ // best-effort, non-fatal — same posture as the POSIX chmod
105
144
  }
106
145
  }
107
146
  function writeFileCredential(appDir, creds) {
@@ -112,12 +151,16 @@ function writeFileCredential(appDir, creds) {
112
151
  host: creds.host,
113
152
  organizationId: creds.organizationId,
114
153
  accounts: { ...creds.accounts },
154
+ ...(creds.insecureHttpAllowed ? { insecureHttpAllowed: true } : {}),
115
155
  };
116
156
  writeFileSync(filePath, `${JSON.stringify(body, null, 2)}\n`, {
117
157
  encoding: "utf-8",
118
158
  mode: 0o600,
119
159
  });
120
- if (process.platform !== "win32") {
160
+ if (process.platform === "win32") {
161
+ restrictWindowsAclBestEffort(filePath);
162
+ }
163
+ else {
121
164
  try {
122
165
  chmodSync(filePath, 0o600);
123
166
  }
@@ -132,19 +175,27 @@ function removeFileCredential(appDir) {
132
175
  try {
133
176
  unlinkSync(filePath);
134
177
  }
135
- catch {
136
- // ignore
178
+ catch (err) {
179
+ // A stale plaintext credentials.json left behind here is exactly the drift loadCredentials warns about.
180
+ mcpLog.warn("credentials_file_remove_failed", { path: filePath, error: err instanceof Error ? err.message : String(err) }, false);
137
181
  }
138
182
  }
139
183
  }
140
- /** Prefer OS keychain when keytar works; otherwise read `credentials.json`. */
184
+ /** Prefer the OS keychain when keytar works; fall back to `credentials.json`. */
141
185
  export async function loadCredentials(appDir = getAppDir()) {
186
+ const fromKeytar = await tryKeytarGet();
187
+ if (fromKeytar) {
188
+ // Keychain is the source of truth; a lingering file is stale and silently shadowed it before this fix.
189
+ // Warn-only (log file, no stderr mirror): drift is recorded without spamming the background sync loop,
190
+ // and the next saveStoredCredentials removes the file. We do not mutate disk on a read.
191
+ if (existsSync(credentialsPath(appDir))) {
192
+ mcpLog.warn("credentials_file_shadowed_by_keychain", { path: credentialsPath(appDir), keytarService: KEYTAR_SERVICE }, false);
193
+ }
194
+ return fromKeytar;
195
+ }
142
196
  const fromFile = loadCredentialsFromFileOnly(appDir);
143
197
  if (fromFile)
144
198
  return fromFile;
145
- const fromKeytar = await tryKeytarGet();
146
- if (fromKeytar)
147
- return fromKeytar;
148
199
  return null;
149
200
  }
150
201
  /**
@@ -154,7 +205,13 @@ export async function saveStoredCredentials(creds, appDir = getAppDir()) {
154
205
  if (!credentialsHaveAnyToken(creds)) {
155
206
  throw new Error("saveStoredCredentials requires at least one account token");
156
207
  }
157
- const payload = JSON.stringify({ version: 2, ...creds, accounts: { ...creds.accounts } });
208
+ const payload = JSON.stringify({
209
+ version: 2,
210
+ host: creds.host,
211
+ organizationId: creds.organizationId,
212
+ accounts: { ...creds.accounts },
213
+ ...(creds.insecureHttpAllowed ? { insecureHttpAllowed: true } : {}),
214
+ });
158
215
  const keytarOk = await tryKeytarSet(payload);
159
216
  if (keytarOk) {
160
217
  removeFileCredential(appDir);
@@ -11,7 +11,7 @@ export interface ExchangeResult {
11
11
  }
12
12
  type ExchangeToolId = "claude_code" | "cursor";
13
13
  export declare function exchangeIngestToken(params: {
14
- db90Host: string;
14
+ apiHost: string;
15
15
  keycloakAccessToken: string;
16
16
  /** Legacy single-tool mint. Omit when tools is provided. */
17
17
  toolName?: ExchangeToolId;
@@ -1,7 +1,7 @@
1
1
  export async function exchangeIngestToken(params) {
2
2
  const fetchFn = params.fetchImpl ?? fetch;
3
3
  const requestedTools = params.tools?.length ? [...params.tools] : params.toolName ? [params.toolName] : [];
4
- const base = params.db90Host.replace(/\/$/, "");
4
+ const base = params.apiHost.replace(/\/$/, "");
5
5
  const url = `${base}/api/v1/integrations/mcp/exchange`;
6
6
  const body = {};
7
7
  if (params.tools?.length) {
@@ -1,7 +1,7 @@
1
1
  import { defaultKeycloakClientId, defaultKeycloakIssuer } from "./keycloak.js";
2
2
  import type { TelemetryToolId } from "./credentials.js";
3
3
  export interface LoginAndPersistOptions {
4
- db90Host: string;
4
+ apiHost: string;
5
5
  keycloakIssuer: string;
6
6
  /** @deprecated Prefer `tools`; kept for callers that mint a single ingest account. */
7
7
  toolName?: string;
@@ -11,12 +11,21 @@ export interface LoginAndPersistOptions {
11
11
  exchangeOrganizationId?: string;
12
12
  clientId?: string;
13
13
  appDir?: string;
14
+ allowInsecureHttp?: boolean;
15
+ /** Skip the "already authenticated" short-circuit and re-run the device flow. */
16
+ force?: boolean;
17
+ onSecurityWarning?: (message: string) => void;
14
18
  onVisitInstructions?: (verification_uri: string, user_code: string) => void;
15
19
  fetchImpl?: typeof fetch;
16
20
  }
17
21
  export declare function loginAndPersistCredentials(opts: LoginAndPersistOptions): Promise<{
18
22
  ok: true;
19
23
  organizationId: string;
24
+ } | {
25
+ ok: true;
26
+ alreadyAuthenticated: true;
27
+ host: string;
28
+ organizationId?: string;
20
29
  } | {
21
30
  ok: false;
22
31
  error: string;
package/dist/auth/flow.js CHANGED
@@ -2,15 +2,37 @@ import { exchangeIngestToken } from "./exchange.js";
2
2
  import { defaultKeycloakClientId, defaultKeycloakIssuer, obtainKeycloakAccessTokenViaDeviceFlow } from "./keycloak.js";
3
3
  import { loadCredentials, saveStoredCredentials } from "./credentials.js";
4
4
  import { getAppDir } from "../state.js";
5
+ import { evaluateTransportSecurity } from "../lib/transport-security.js";
6
+ function normalizeHostForComparison(host) {
7
+ return host.replace(/\/$/, "");
8
+ }
9
+ function credentialsCoverRequestedTools(creds, requestedTools) {
10
+ return requestedTools.every((tool) => {
11
+ const token = creds.accounts[tool];
12
+ return typeof token === "string" && token.length > 0;
13
+ });
14
+ }
15
+ function credentialsMatchRequestedScope(creds, opts, requestedTools) {
16
+ if (normalizeHostForComparison(creds.host) !== normalizeHostForComparison(opts.apiHost))
17
+ return false;
18
+ if (opts.exchangeOrganizationId && creds.organizationId !== opts.exchangeOrganizationId)
19
+ return false;
20
+ return credentialsCoverRequestedTools(creds, requestedTools);
21
+ }
5
22
  export async function loginAndPersistCredentials(opts) {
23
+ const appDir = opts.appDir ?? getAppDir();
24
+ const requestedTools = opts.tools ?? ((opts.toolName ?? "claude_code") === "cursor" ? ["cursor"] : ["claude_code"]);
25
+ if (!opts.force) {
26
+ const existing = await loadCredentials(appDir);
27
+ if (existing && credentialsMatchRequestedScope(existing, opts, requestedTools)) {
28
+ return { ok: true, alreadyAuthenticated: true, host: existing.host, organizationId: existing.organizationId };
29
+ }
30
+ }
6
31
  const issuer = opts.keycloakIssuer.trim();
7
32
  if (!issuer) {
8
33
  return { ok: false, error: "Keycloak issuer is empty; set KEYCLOAK_ISSUER or pass --keycloak-url." };
9
34
  }
10
35
  const clientId = opts.clientId?.trim() || defaultKeycloakClientId();
11
- const appDir = opts.appDir ?? getAppDir();
12
- const requestedTools = opts.tools ??
13
- ((opts.toolName ?? "claude_code") === "cursor" ? ["cursor"] : ["claude_code"]);
14
36
  let accessToken;
15
37
  try {
16
38
  accessToken = await obtainKeycloakAccessTokenViaDeviceFlow({
@@ -27,7 +49,7 @@ export async function loginAndPersistCredentials(opts) {
27
49
  try {
28
50
  if (requestedTools.length > 1) {
29
51
  exchanged = await exchangeIngestToken({
30
- db90Host: opts.db90Host,
52
+ apiHost: opts.apiHost,
31
53
  keycloakAccessToken: accessToken,
32
54
  tools: requestedTools,
33
55
  deviceLabel: opts.deviceLabel,
@@ -37,7 +59,7 @@ export async function loginAndPersistCredentials(opts) {
37
59
  }
38
60
  else {
39
61
  exchanged = await exchangeIngestToken({
40
- db90Host: opts.db90Host,
62
+ apiHost: opts.apiHost,
41
63
  keycloakAccessToken: accessToken,
42
64
  toolName: requestedTools[0],
43
65
  deviceLabel: opts.deviceLabel,
@@ -45,11 +67,22 @@ export async function loginAndPersistCredentials(opts) {
45
67
  fetchImpl: opts.fetchImpl,
46
68
  });
47
69
  }
70
+ const ingestHostSecurity = evaluateTransportSecurity(exchanged.ingestHost, {
71
+ allowInsecureHttp: opts.allowInsecureHttp === true,
72
+ label: "DB90 ingest host",
73
+ });
74
+ if (!ingestHostSecurity.ok) {
75
+ return { ok: false, error: ingestHostSecurity.error };
76
+ }
77
+ if (ingestHostSecurity.warning) {
78
+ opts.onSecurityWarning?.(ingestHostSecurity.warning);
79
+ }
48
80
  const existing = await loadCredentials(appDir);
49
81
  const stored = {
50
82
  host: exchanged.ingestHost,
51
83
  organizationId: exchanged.organizationId,
52
84
  accounts: existing?.host === exchanged.ingestHost ? { ...existing.accounts } : {},
85
+ ...(opts.allowInsecureHttp === true ? { insecureHttpAllowed: true } : {}),
53
86
  };
54
87
  for (const tid of ["claude_code", "cursor"]) {
55
88
  const acc = exchanged.accounts[tid];
@@ -31,5 +31,5 @@ export declare function obtainKeycloakAccessTokenViaDeviceFlow(params: {
31
31
  onInstructions?: (verification_uri: string, user_code: string) => void;
32
32
  fetchImpl?: typeof fetch;
33
33
  }): Promise<string>;
34
- export declare function defaultKeycloakIssuer(): string;
34
+ export declare function defaultKeycloakIssuer(ingestHost?: string): string;
35
35
  export declare function defaultKeycloakClientId(): string;
@@ -2,6 +2,7 @@
2
2
  * RFC 8628 OAuth 2.0 Device Authorization Grant against Keycloak OIDC endpoints.
3
3
  */
4
4
  import { createHash, randomBytes } from "node:crypto";
5
+ import { isLoopbackHost } from "../lib/transport-security.js";
5
6
  function normalizeIssuer(issuer) {
6
7
  return issuer.replace(/\/$/, "");
7
8
  }
@@ -154,13 +155,31 @@ export async function obtainKeycloakAccessTokenViaDeviceFlow(params) {
154
155
  fetchImpl: params.fetchImpl,
155
156
  });
156
157
  }
157
- export function defaultKeycloakIssuer() {
158
+ export function defaultKeycloakIssuer(ingestHost) {
158
159
  const fromEnv = process.env["DB90_KEYCLOAK_ISSUER"]?.trim() ||
159
160
  process.env["KEYCLOAK_ISSUER"]?.trim();
160
161
  if (fromEnv)
161
162
  return fromEnv.replace(/\/$/, "");
162
163
  const useLocalDefault = ["1", "true", "yes"].includes(process.env["DB90_MCP_USE_LOCAL_KEYCLOAK_DEFAULT"]?.toLowerCase() ?? "");
163
164
  if (useLocalDefault || process.env["NODE_ENV"] === "development") {
165
+ if (ingestHost) {
166
+ let hostname;
167
+ try {
168
+ hostname = new URL(ingestHost).hostname;
169
+ }
170
+ catch {
171
+ hostname = ingestHost;
172
+ }
173
+ if (!isLoopbackHost(hostname)) {
174
+ const reason = useLocalDefault
175
+ ? `DB90_MCP_USE_LOCAL_KEYCLOAK_DEFAULT=${process.env["DB90_MCP_USE_LOCAL_KEYCLOAK_DEFAULT"]}`
176
+ : `NODE_ENV=development`;
177
+ console.error(`[aixle-insights] Warning: ${reason} would redirect Keycloak authentication to ` +
178
+ `http://localhost:8080, but the ingest host "${ingestHost}" is not localhost. ` +
179
+ `Ignoring the local Keycloak default. Set DB90_KEYCLOAK_ISSUER explicitly.`);
180
+ return "";
181
+ }
182
+ }
164
183
  return "http://localhost:8080/realms/db90";
165
184
  }
166
185
  return "";
package/dist/cli.d.ts CHANGED
@@ -7,8 +7,9 @@ import { migrateLegacyState, getAppDir } from "./state.js";
7
7
  import { syncTelemetryTools } from "./sync.js";
8
8
  import { mergePricing } from "./pricing.js";
9
9
  import { type InstallClaudeUserMcpOptions, type InstallResult } from "./install/claude.js";
10
+ import { type InstallCursorUserMcpOptions } from "./install/cursor.js";
10
11
  export interface Args {
11
- command: "init" | "health" | "run" | "help" | "uninstall-hooks" | "verify-hooks";
12
+ command: "init" | "health" | "run" | "help" | "uninstall-hooks" | "verify-hooks" | "uninstall-cursor-mcp";
12
13
  help: boolean;
13
14
  once: boolean;
14
15
  /** With `run --once`: ignore Cursor watermarks and commit hash dedupe. */
@@ -21,6 +22,8 @@ export interface Args {
21
22
  force?: boolean;
22
23
  /** When set on init, install the Cursor hooks forwarder into ~/.cursor/hooks.json. */
23
24
  hooks?: boolean;
25
+ /** Allow remote plaintext HTTP hosts for trusted non-production test environments. */
26
+ insecure?: boolean;
24
27
  }
25
28
  interface RunOnceDeps {
26
29
  loadCredentials: typeof loadCredentials;
@@ -37,12 +40,13 @@ interface InitDeps {
37
40
  defaultKeycloakIssuer: typeof defaultKeycloakIssuer;
38
41
  getAppDir: typeof getAppDir;
39
42
  installClaudeUserMcp: (options: InstallClaudeUserMcpOptions) => InstallResult;
43
+ installCursorUserMcp: (options: InstallCursorUserMcpOptions) => InstallResult;
40
44
  log: (message: string) => void;
41
45
  error: (message: string) => void;
42
46
  }
43
47
  /** Matches DB90 Rails `McpController` UUID check for `X-Organization-ID` (RFC 4122 variant). */
44
- export declare const DB90_ORGANIZATION_UUID_PATTERN: RegExp;
45
- export declare function isValidDb90OrganizationUuid(value: string): boolean;
48
+ export declare const ORGANIZATION_UUID_PATTERN: RegExp;
49
+ export declare function isValidOrganizationUuid(value: string): boolean;
46
50
  export declare function parseArgs(argv: string[]): Args;
47
51
  export declare function runInit(cliArgs: Args, deps?: Partial<InitDeps>): Promise<number>;
48
52
  export declare function runOnce(deps?: Partial<RunOnceDeps>, options?: {