@aixle/insights 0.2.0 → 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 (45) hide show
  1. package/README.md +36 -8
  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 +8 -1
  7. package/dist/auth/flow.js +27 -5
  8. package/dist/auth/keycloak.d.ts +1 -1
  9. package/dist/auth/keycloak.js +20 -1
  10. package/dist/cli.d.ts +5 -3
  11. package/dist/cli.js +69 -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 +6 -0
  17. package/dist/cursor-settings.d.ts +9 -4
  18. package/dist/cursor-settings.js +80 -10
  19. package/dist/health.d.ts +3 -1
  20. package/dist/health.js +13 -1
  21. package/dist/hooks/cursor-hooks-mapper.d.ts +3 -3
  22. package/dist/hooks/cursor-hooks-mapper.js +1 -1
  23. package/dist/hooks/cursor-hooks-reader.d.ts +2 -0
  24. package/dist/hooks/cursor-hooks-reader.js +2 -2
  25. package/dist/install/cursor.d.ts +34 -0
  26. package/dist/install/cursor.js +193 -0
  27. package/dist/install/index.d.ts +6 -4
  28. package/dist/install/index.js +6 -1
  29. package/dist/lib/client.d.ts +7 -0
  30. package/dist/lib/client.js +17 -0
  31. package/dist/lib/config.js +7 -2
  32. package/dist/lib/project-resolver.d.ts +5 -4
  33. package/dist/lib/project-resolver.js +20 -8
  34. package/dist/lib/transport-security.d.ts +1 -0
  35. package/dist/lib/transport-security.js +1 -1
  36. package/dist/readers/claude.d.ts +54 -6
  37. package/dist/readers/claude.js +154 -2
  38. package/dist/readers/cursor.d.ts +10 -7
  39. package/dist/readers/cursor.js +101 -15
  40. package/dist/server.d.ts +20 -3
  41. package/dist/server.js +101 -67
  42. package/dist/state.js +7 -2
  43. package/dist/sync.d.ts +4 -2
  44. package/dist/sync.js +61 -46
  45. package/package.json +2 -2
package/README.md CHANGED
@@ -98,7 +98,7 @@ After `init` succeeds:
98
98
 
99
99
  1. **Restart Claude Code** — it discovers the new MCP server on the next launch.
100
100
  2. Open Claude Code; confirm `/mcp` lists **aixle-insights**.
101
- 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.
102
102
 
103
103
  ## Multi-org
104
104
 
@@ -113,6 +113,14 @@ npx -y @aixle/insights init \
113
113
 
114
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.
115
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
+
116
124
  ## Commands
117
125
 
118
126
  | Command | What it does |
@@ -158,20 +166,23 @@ Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per
158
166
 
159
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.
160
168
 
161
- ### Two gates
169
+ ### Three gates
162
170
 
163
171
  | Gate | Where it fires | What it checks |
164
172
  |---|---|---|
165
173
  | CLI `--host` gate | `runInit()` at the top of `aixle-insights init`, before any network call to Keycloak | The `--host` value the user typed |
166
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.
167
178
 
168
- Both gates use the same pure utility, `evaluateTransportSecurity()` in `src/lib/transport-security.ts`. Either gate rejecting aborts `init` with exit code 1 and a single-line error naming the offending host.
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.
169
180
 
170
- ### `--insecure` (init-only)
181
+ ### `--insecure` (init-only, consent persists to runtime)
171
182
 
172
- `aixle-insights init --insecure --host http://<remote>` downgrades both 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).
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).
173
184
 
174
- `--insecure` is rejected on the `run` subcommand by design — the long-running MCP server should never run insecurely. `run --insecure` routes to the help screen, the same as any unknown flag.
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.
175
186
 
176
187
  ### What is NOT gated
177
188
 
@@ -184,9 +195,25 @@ The Keycloak issuer URL (`--keycloak-url` / `KEYCLOAK_ISSUER`) is **not** TLS-ga
184
195
  ## State + credentials
185
196
 
186
197
  - **App home directory**: `~/.aixle-insights/` (override with `AIXLE_INSIGHTS_HOME`).
187
- - **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.
188
199
  - **State files**: `state-<hostname>-<token-hash>.json` per credential, plus `state.lock` advisory lock, `mcp.log` rotating diagnostic log, optional `hooks-queue.ndjson`.
189
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
+
190
217
  The internal state-file shape is implementation-detail; don't depend on it from outside this package.
191
218
 
192
219
  ## Diagnostics
@@ -196,13 +223,14 @@ aixle-insights health # connectivity + last sync metadata
196
223
  aixle-insights verify-hooks # JSON: hooks installed + queue depth
197
224
  ```
198
225
 
199
- `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`.
200
227
 
201
228
  ## Troubleshooting
202
229
 
203
230
  | Symptom | Most likely cause | Fix |
204
231
  |---|---|---|
205
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. |
206
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. |
207
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. |
208
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. |
@@ -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;
@@ -12,6 +12,8 @@ export interface LoginAndPersistOptions {
12
12
  clientId?: string;
13
13
  appDir?: string;
14
14
  allowInsecureHttp?: boolean;
15
+ /** Skip the "already authenticated" short-circuit and re-run the device flow. */
16
+ force?: boolean;
15
17
  onSecurityWarning?: (message: string) => void;
16
18
  onVisitInstructions?: (verification_uri: string, user_code: string) => void;
17
19
  fetchImpl?: typeof fetch;
@@ -19,6 +21,11 @@ export interface LoginAndPersistOptions {
19
21
  export declare function loginAndPersistCredentials(opts: LoginAndPersistOptions): Promise<{
20
22
  ok: true;
21
23
  organizationId: string;
24
+ } | {
25
+ ok: true;
26
+ alreadyAuthenticated: true;
27
+ host: string;
28
+ organizationId?: string;
22
29
  } | {
23
30
  ok: false;
24
31
  error: string;
package/dist/auth/flow.js CHANGED
@@ -3,15 +3,36 @@ import { defaultKeycloakClientId, defaultKeycloakIssuer, obtainKeycloakAccessTok
3
3
  import { loadCredentials, saveStoredCredentials } from "./credentials.js";
4
4
  import { getAppDir } from "../state.js";
5
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
+ }
6
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
+ }
7
31
  const issuer = opts.keycloakIssuer.trim();
8
32
  if (!issuer) {
9
33
  return { ok: false, error: "Keycloak issuer is empty; set KEYCLOAK_ISSUER or pass --keycloak-url." };
10
34
  }
11
35
  const clientId = opts.clientId?.trim() || defaultKeycloakClientId();
12
- const appDir = opts.appDir ?? getAppDir();
13
- const requestedTools = opts.tools ??
14
- ((opts.toolName ?? "claude_code") === "cursor" ? ["cursor"] : ["claude_code"]);
15
36
  let accessToken;
16
37
  try {
17
38
  accessToken = await obtainKeycloakAccessTokenViaDeviceFlow({
@@ -28,7 +49,7 @@ export async function loginAndPersistCredentials(opts) {
28
49
  try {
29
50
  if (requestedTools.length > 1) {
30
51
  exchanged = await exchangeIngestToken({
31
- db90Host: opts.db90Host,
52
+ apiHost: opts.apiHost,
32
53
  keycloakAccessToken: accessToken,
33
54
  tools: requestedTools,
34
55
  deviceLabel: opts.deviceLabel,
@@ -38,7 +59,7 @@ export async function loginAndPersistCredentials(opts) {
38
59
  }
39
60
  else {
40
61
  exchanged = await exchangeIngestToken({
41
- db90Host: opts.db90Host,
62
+ apiHost: opts.apiHost,
42
63
  keycloakAccessToken: accessToken,
43
64
  toolName: requestedTools[0],
44
65
  deviceLabel: opts.deviceLabel,
@@ -61,6 +82,7 @@ export async function loginAndPersistCredentials(opts) {
61
82
  host: exchanged.ingestHost,
62
83
  organizationId: exchanged.organizationId,
63
84
  accounts: existing?.host === exchanged.ingestHost ? { ...existing.accounts } : {},
85
+ ...(opts.allowInsecureHttp === true ? { insecureHttpAllowed: true } : {}),
64
86
  };
65
87
  for (const tid of ["claude_code", "cursor"]) {
66
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. */
@@ -39,12 +40,13 @@ interface InitDeps {
39
40
  defaultKeycloakIssuer: typeof defaultKeycloakIssuer;
40
41
  getAppDir: typeof getAppDir;
41
42
  installClaudeUserMcp: (options: InstallClaudeUserMcpOptions) => InstallResult;
43
+ installCursorUserMcp: (options: InstallCursorUserMcpOptions) => InstallResult;
42
44
  log: (message: string) => void;
43
45
  error: (message: string) => void;
44
46
  }
45
47
  /** Matches DB90 Rails `McpController` UUID check for `X-Organization-ID` (RFC 4122 variant). */
46
- export declare const DB90_ORGANIZATION_UUID_PATTERN: RegExp;
47
- export declare function isValidDb90OrganizationUuid(value: string): boolean;
48
+ export declare const ORGANIZATION_UUID_PATTERN: RegExp;
49
+ export declare function isValidOrganizationUuid(value: string): boolean;
48
50
  export declare function parseArgs(argv: string[]): Args;
49
51
  export declare function runInit(cliArgs: Args, deps?: Partial<InitDeps>): Promise<number>;
50
52
  export declare function runOnce(deps?: Partial<RunOnceDeps>, options?: {