@cachly-dev/mcp-server 0.10.27 → 0.10.40

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 (79) hide show
  1. package/.env.test.example +23 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +50 -0
  3. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +25 -0
  5. package/.github/workflows/publish.yml +72 -0
  6. package/CHANGELOG.md +252 -0
  7. package/LAUNCH_TEXTS.md +182 -0
  8. package/LICENSE +202 -0
  9. package/README.md +142 -116
  10. package/dist/__tests__/auth.test.d.ts +1 -0
  11. package/dist/__tests__/auth.test.js +107 -0
  12. package/dist/__tests__/auth.test.js.map +1 -0
  13. package/dist/__tests__/e2e/api-auth.test.d.ts +7 -0
  14. package/dist/__tests__/e2e/api-auth.test.js +162 -0
  15. package/dist/__tests__/e2e/api-auth.test.js.map +1 -0
  16. package/dist/__tests__/e2e/api-health.test.d.ts +7 -0
  17. package/dist/__tests__/e2e/api-health.test.js +95 -0
  18. package/dist/__tests__/e2e/api-health.test.js.map +1 -0
  19. package/dist/__tests__/e2e/cli.test.d.ts +10 -0
  20. package/dist/__tests__/e2e/cli.test.js +204 -0
  21. package/dist/__tests__/e2e/cli.test.js.map +1 -0
  22. package/dist/__tests__/e2e/config.d.ts +35 -0
  23. package/dist/__tests__/e2e/config.js +53 -0
  24. package/dist/__tests__/e2e/config.js.map +1 -0
  25. package/dist/__tests__/e2e/funnel.test.d.ts +13 -0
  26. package/dist/__tests__/e2e/funnel.test.js +90 -0
  27. package/dist/__tests__/e2e/funnel.test.js.map +1 -0
  28. package/dist/__tests__/e2e/mcp-tools.test.d.ts +7 -0
  29. package/dist/__tests__/e2e/mcp-tools.test.js +129 -0
  30. package/dist/__tests__/e2e/mcp-tools.test.js.map +1 -0
  31. package/dist/__tests__/e2e/provisioning.test.d.ts +15 -0
  32. package/dist/__tests__/e2e/provisioning.test.js +123 -0
  33. package/dist/__tests__/e2e/provisioning.test.js.map +1 -0
  34. package/dist/handlers/advanced.js +38 -227
  35. package/dist/handlers/advanced.js.map +1 -1
  36. package/dist/handlers/brain.js +119 -82
  37. package/dist/handlers/brain.js.map +1 -1
  38. package/dist/handlers/fedbrain.js +93 -44
  39. package/dist/handlers/fedbrain.js.map +1 -1
  40. package/dist/handlers/roadmap.js +8 -2
  41. package/dist/handlers/roadmap.js.map +1 -1
  42. package/dist/handlers/syndicate.js +15 -7
  43. package/dist/handlers/syndicate.js.map +1 -1
  44. package/dist/handlers/team.js +62 -50
  45. package/dist/handlers/team.js.map +1 -1
  46. package/dist/index.js +1264 -234
  47. package/dist/index.js.map +1 -1
  48. package/dist/tools.d.ts +3 -54
  49. package/dist/tools.js +8 -62
  50. package/dist/tools.js.map +1 -1
  51. package/eslint.config.js +16 -0
  52. package/glama.json +36 -0
  53. package/llms.txt +155 -0
  54. package/package.json +38 -32
  55. package/scripts/e2e-run.sh +160 -0
  56. package/scripts/postinstall.js +12 -12
  57. package/server.json +3 -9
  58. package/smithery.yaml +15 -8
  59. package/src/__tests__/auth.test.ts +147 -0
  60. package/src/__tests__/e2e/api-auth.test.ts +182 -0
  61. package/src/__tests__/e2e/api-health.test.ts +103 -0
  62. package/src/__tests__/e2e/cli.test.ts +228 -0
  63. package/src/__tests__/e2e/config.ts +53 -0
  64. package/src/__tests__/e2e/funnel.test.ts +99 -0
  65. package/src/__tests__/e2e/mcp-tools.test.ts +191 -0
  66. package/src/__tests__/e2e/provisioning.test.ts +128 -0
  67. package/src/handlers/advanced.ts +37 -217
  68. package/src/handlers/brain.ts +118 -78
  69. package/src/handlers/fedbrain.ts +88 -38
  70. package/src/handlers/roadmap.ts +7 -2
  71. package/src/handlers/syndicate.ts +13 -7
  72. package/src/handlers/team.ts +57 -33
  73. package/src/index.ts +1312 -257
  74. package/src/tools.ts +8 -66
  75. package/vitest.config.ts +12 -0
  76. package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
  77. package/dist/__tests__/brain-predict-failures.test.js +0 -339
  78. package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
  79. package/src/__tests__/brain-predict-failures.test.ts +0 -430
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env bash
2
+ # ─────────────────────────────────────────────────────────────────────────────
3
+ # cachly E2E test runner
4
+ #
5
+ # Usage:
6
+ # ./scripts/e2e-run.sh # run all e2e tests
7
+ # ./scripts/e2e-run.sh api # only API health + auth tests
8
+ # ./scripts/e2e-run.sh tools # only MCP tool tests (needs Redis creds)
9
+ # ./scripts/e2e-run.sh cli # only CLI smoke tests
10
+ # ./scripts/e2e-run.sh funnel # only funnel telemetry tests
11
+ # ./scripts/e2e-run.sh provisioning # provisioning lifecycle (slow, ~5 min)
12
+ #
13
+ # Required env vars:
14
+ # E2E_API_URL https://api.dev.cachly.dev (or prod)
15
+ # E2E_JWT <keycloak access token>
16
+ # E2E_INSTANCE_ID <uuid of active test Brain>
17
+ # E2E_API_KEY <redis password>
18
+ # E2E_REDIS_HOST <redis host>
19
+ #
20
+ # Optional:
21
+ # E2E_REDIS_PORT 6380
22
+ # E2E_REDIS_TLS true
23
+ # E2E_SKIP_SLOW true (skip provisioning + device flow)
24
+ # ─────────────────────────────────────────────────────────────────────────────
25
+
26
+ set -euo pipefail
27
+
28
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
29
+ cd "$ROOT"
30
+
31
+ # ── Load .env.test if present (convenience for local dev) ─────────────────────
32
+ if [[ -f "$ROOT/.env.test" ]]; then
33
+ set -a
34
+ # shellcheck source=/dev/null
35
+ source "$ROOT/.env.test"
36
+ set +a
37
+ echo -e "\033[90m[e2e] Loaded $ROOT/.env.test\033[0m"
38
+ fi
39
+
40
+ # ── Defaults ──────────────────────────────────────────────────────────────────
41
+ export E2E_API_URL="${E2E_API_URL:-https://api.cachly.dev}"
42
+ export E2E_REDIS_PORT="${E2E_REDIS_PORT:-6380}"
43
+ export E2E_REDIS_TLS="${E2E_REDIS_TLS:-true}"
44
+ export E2E_SKIP_SLOW="${E2E_SKIP_SLOW:-false}"
45
+
46
+ # ── Color output ──────────────────────────────────────────────────────────────
47
+ RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
48
+
49
+ header() { echo -e "\n${YELLOW}━━━ $1 ━━━${NC}"; }
50
+ ok() { echo -e "${GREEN}✓ $1${NC}"; }
51
+ fail() { echo -e "${RED}✗ $1${NC}"; }
52
+
53
+ # ── Pre-flight checks ─────────────────────────────────────────────────────────
54
+ header "Pre-flight"
55
+
56
+ if [[ -z "${E2E_JWT:-}" ]]; then
57
+ fail "E2E_JWT is not set. Export a valid Keycloak token."
58
+ echo " Hint: copy your JWT from https://${E2E_API_URL}/api/v1/debug/me after login"
59
+ exit 1
60
+ fi
61
+ ok "E2E_JWT is set (length: ${#E2E_JWT})"
62
+
63
+ if [[ -z "${E2E_INSTANCE_ID:-}" ]]; then
64
+ echo -e "${YELLOW}⚠ E2E_INSTANCE_ID not set — instance-specific tests will be skipped${NC}"
65
+ else
66
+ ok "E2E_INSTANCE_ID: $E2E_INSTANCE_ID"
67
+ fi
68
+
69
+ # ── Build ─────────────────────────────────────────────────────────────────────
70
+ header "Building"
71
+ npm run build
72
+ ok "Build complete"
73
+
74
+ # ── Select test suite ─────────────────────────────────────────────────────────
75
+ SUITE="${1:-all}"
76
+
77
+ VITEST_BASE="npx vitest run --reporter=verbose"
78
+ E2E_DIR="src/__tests__/e2e"
79
+
80
+ run_suite() {
81
+ local name="$1"; local pattern="$2"
82
+ header "Running: $name"
83
+ $VITEST_BASE "$pattern" && ok "$name passed" || { fail "$name FAILED"; exit 1; }
84
+ }
85
+
86
+ case "$SUITE" in
87
+ api)
88
+ run_suite "API health + auth" "$E2E_DIR/api-health.test.ts $E2E_DIR/api-auth.test.ts"
89
+ ;;
90
+ tools)
91
+ if [[ -z "${E2E_API_KEY:-}" || -z "${E2E_REDIS_HOST:-}" ]]; then
92
+ fail "E2E_API_KEY and E2E_REDIS_HOST required for MCP tool tests"
93
+ exit 1
94
+ fi
95
+ run_suite "MCP tools" "$E2E_DIR/mcp-tools.test.ts"
96
+ ;;
97
+ cli)
98
+ run_suite "CLI smoke" "$E2E_DIR/cli.test.ts"
99
+ ;;
100
+ funnel)
101
+ run_suite "Funnel telemetry" "$E2E_DIR/funnel.test.ts"
102
+ ;;
103
+ provisioning)
104
+ export E2E_SKIP_SLOW=false
105
+ run_suite "Provisioning lifecycle" "$E2E_DIR/provisioning.test.ts"
106
+ ;;
107
+ all)
108
+ header "Running ALL e2e suites"
109
+
110
+ echo ""
111
+ echo " API URL: $E2E_API_URL"
112
+ echo " Instance ID: ${E2E_INSTANCE_ID:-(not set)}"
113
+ echo " Redis host: ${E2E_REDIS_HOST:-(not set — tool tests will fail)}"
114
+ echo " Skip slow: $E2E_SKIP_SLOW"
115
+ echo ""
116
+
117
+ FAILED=()
118
+
119
+ run_one() {
120
+ local name="$1"; local file="$2"
121
+ header "$name"
122
+ if $VITEST_BASE "$file"; then
123
+ ok "$name passed"
124
+ else
125
+ fail "$name FAILED"
126
+ FAILED+=("$name")
127
+ fi
128
+ }
129
+
130
+ run_one "API health (no auth)" "$E2E_DIR/api-health.test.ts"
131
+ run_one "API auth + instances" "$E2E_DIR/api-auth.test.ts"
132
+ run_one "Funnel telemetry" "$E2E_DIR/funnel.test.ts"
133
+ run_one "CLI smoke tests" "$E2E_DIR/cli.test.ts"
134
+
135
+ if [[ -n "${E2E_API_KEY:-}" && -n "${E2E_REDIS_HOST:-}" ]]; then
136
+ run_one "MCP tools (live Redis)" "$E2E_DIR/mcp-tools.test.ts"
137
+ else
138
+ echo -e "${YELLOW}⚠ Skipping MCP tool tests (E2E_API_KEY / E2E_REDIS_HOST not set)${NC}"
139
+ fi
140
+
141
+ if [[ "$E2E_SKIP_SLOW" != "true" ]]; then
142
+ run_one "Provisioning lifecycle" "$E2E_DIR/provisioning.test.ts"
143
+ else
144
+ echo -e "${YELLOW}⚠ Skipping provisioning tests (E2E_SKIP_SLOW=true)${NC}"
145
+ fi
146
+
147
+ echo ""
148
+ if [[ ${#FAILED[@]} -eq 0 ]]; then
149
+ echo -e "${GREEN}━━━ ALL E2E TESTS PASSED ━━━${NC}"
150
+ else
151
+ echo -e "${RED}━━━ FAILED SUITES: ${FAILED[*]} ━━━${NC}"
152
+ exit 1
153
+ fi
154
+ ;;
155
+ *)
156
+ echo "Unknown suite: $SUITE"
157
+ echo "Usage: $0 [all|api|tools|cli|funnel|provisioning]"
158
+ exit 1
159
+ ;;
160
+ esac
@@ -2,28 +2,28 @@
2
2
  // Shown once after: npm install @cachly-dev/mcp-server
3
3
 
4
4
  const c = {
5
- reset: "\x1b[0m",
6
- bold: "\x1b[1m",
5
+ reset: "\x1b[0m",
6
+ bold: "\x1b[1m",
7
7
  violet: "\x1b[35m",
8
- green: "\x1b[32m",
9
- cyan: "\x1b[36m",
8
+ green: "\x1b[32m",
9
+ cyan: "\x1b[36m",
10
+ dim: "\x1b[2m",
10
11
  yellow: "\x1b[33m",
11
- dim: "\x1b[2m",
12
12
  };
13
13
 
14
14
  const line = `${c.dim}─────────────────────────────────────────────────────${c.reset}`;
15
15
 
16
16
  console.log(`
17
17
  ${line}
18
- ${c.bold}${c.violet}🧠 Cachly Brain MCP Server${c.reset} ${c.dim}v${process.env.npm_package_version ?? ""}${c.reset}
18
+ ${c.bold}${c.violet}🧠 cachly AI Brain${c.reset} ${c.dim}v${process.env.npm_package_version ?? ""}${c.reset}
19
19
 
20
- ${c.bold}Your AI forgets everything after each session. This fixes that.${c.reset}
20
+ ${c.bold}One command sets everything up:${c.reset}
21
21
 
22
- ${c.green}Setup in 30 seconds (auto-detects all your editors):${c.reset}
23
- ${c.cyan}npx @cachly-dev/init${c.reset}
22
+ ${c.cyan}npx @cachly-dev/mcp-server@latest setup${c.reset}
24
23
 
25
- ${c.dim}Claude Code · Cursor · GitHub Copilot · Windsurf · Continue${c.reset}
26
- ${c.dim}Free tier · GDPR · EU servers · No credit card${c.reset}
24
+ Signs you in → detects your editors → writes all MCP configs
25
+ automatically → adds Brain rules to CLAUDE.md.
27
26
 
27
+ ${c.green}No credentials. No credit card. Free forever.${c.reset}
28
+ ${c.dim}Other editors: https://cachly.dev/docs/mcp${c.reset}
28
29
  ${line}`);
29
-
package/server.json CHANGED
@@ -1,27 +1,21 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.cachly-dev/cachly-mcp",
4
- "description": "Give Claude a persistent memory that never forgets. cachly learns automatically from every session, bug fix, and git commit — no manual calls needed. Arrives pre-briefed. Predicts failures before they happen. One-click sign-in (OAuth Device Flow) · 63 tools · Free tier · EU servers · works with Claude Code, Cursor, Copilot, Windsurf.",
4
+ "description": "Your AI forgets everything between sessions. cachly fixes that permanently. A causal brain that learns WHY things break — built from every bug fix, git commit, and CI run, without any explicit calls. Arrives pre-briefed each session. Predicts failures before they happen. Never re-researches the same problem. 89 MCP tools · Free tier · EU servers · GDPR.",
5
5
  "repository": {
6
6
  "url": "https://github.com/cachly-dev/cachly-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.10.1",
9
+ "version": "0.10.23",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "name": "@cachly-dev/mcp-server",
14
14
  "identifier": "@cachly-dev/mcp-server",
15
- "version": "0.10.1",
15
+ "version": "0.10.23",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
19
19
  }
20
- ],
21
- "remotes": [
22
- {
23
- "transportType": "streamable-http",
24
- "url": "https://mcp.cachly.dev/mcp"
25
- }
26
20
  ]
27
21
  }
package/smithery.yaml CHANGED
@@ -1,3 +1,12 @@
1
+ name: cachly AI Brain
2
+ description: >
3
+ Persistent AI memory for Claude Code, Cursor, Copilot, Windsurf, Cline & Zed.
4
+ Your AI remembers every lesson, fix, and architecture decision — forever.
5
+ Features: causal root-cause analysis (causal_trace), failure prediction (brain_predict),
6
+ git-ambient learning (brain_from_git), team knowledge sharing, Memory Crystals,
7
+ BM25+ search in 11 languages. 89 MCP tools. Free tier. GDPR. German servers.
8
+ Zero-config: sign in once via browser, auto-provisions your Brain instance.
9
+
1
10
  startCommand:
2
11
  type: stdio
3
12
  configSchema:
@@ -5,23 +14,21 @@ startCommand:
5
14
  properties:
6
15
  CACHLY_JWT:
7
16
  type: string
8
- title: "API Key"
9
- description: "Optional. Leave blank — on your first tool call the server starts a one-click browser sign-in (OAuth Device Flow) and auto-provisions your free Brain instance. No copy-paste, no dashboard visit required. Or pre-fill with your API key from cachly.dev/setup-ai."
10
- default: ""
17
+ title: "API Key (optional — sign in via browser on first use)"
18
+ description: "Leave blank for zero-config: on your first tool call the server opens a browser sign-in (OAuth Device Flow) and auto-provisions your Brain instance. Or pre-fill with your API key from cachly.dev/setup-ai."
11
19
  examples:
12
20
  - "cky_live_abc123..."
13
21
  CACHLY_BRAIN_INSTANCE_ID:
14
22
  type: string
15
- title: "Brain Instance ID"
16
- description: "Optional. Leave blank — the server auto-detects your active instance. Only set this if you have multiple Brain instances."
17
- default: ""
23
+ title: "Brain Instance ID (optional)"
24
+ description: "Only needed if you have multiple Brain instances. Leave blank — the server auto-detects your active instance from the API key."
18
25
  pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
19
26
  examples:
20
27
  - "9d4077aa-bfa2-468b-89cd-0a8d8f3ec483"
21
28
  CACHLY_API_URL:
22
29
  type: string
23
- title: "API URL"
24
- description: "Optional. Leave blank unless you self-host cachly."
30
+ title: "API URL (optional)"
31
+ description: "Leave blank unless you self-host Cachly."
25
32
  default: "https://api.cachly.dev"
26
33
  required: []
27
34
  commandFunction: |-
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
3
+ import { jwtExpiryMs, checkJwt, handleApiError } from '../auth.js';
4
+
5
+ function makeJwt(payload: Record<string, unknown>): string {
6
+ const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
7
+ const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
8
+ return `${header}.${body}.fakesig`;
9
+ }
10
+
11
+ const nowSec = () => Math.floor(Date.now() / 1000);
12
+
13
+ describe('jwtExpiryMs', () => {
14
+ it('returns null for an empty string', () => {
15
+ expect(jwtExpiryMs('')).toBeNull();
16
+ });
17
+
18
+ it('returns null for a plain (non-JWT) string', () => {
19
+ expect(jwtExpiryMs('not-a-token')).toBeNull();
20
+ });
21
+
22
+ it('returns null when the JWT has only two parts', () => {
23
+ expect(jwtExpiryMs('header.payload')).toBeNull();
24
+ });
25
+
26
+ it('returns null when the JWT has four parts', () => {
27
+ expect(jwtExpiryMs('a.b.c.d')).toBeNull();
28
+ });
29
+
30
+ it('returns null when the payload base64 is invalid', () => {
31
+ expect(jwtExpiryMs('header.!!!.sig')).toBeNull();
32
+ });
33
+
34
+ it('returns null when the payload has no exp field', () => {
35
+ expect(jwtExpiryMs(makeJwt({ sub: 'user-123', iat: nowSec() }))).toBeNull();
36
+ });
37
+
38
+ it('returns null when exp is 0', () => {
39
+ expect(jwtExpiryMs(makeJwt({ exp: 0 }))).toBeNull();
40
+ });
41
+
42
+ it('converts exp (seconds) to milliseconds', () => {
43
+ const expSec = nowSec() + 3600;
44
+ expect(jwtExpiryMs(makeJwt({ exp: expSec }))).toBe(expSec * 1000);
45
+ });
46
+
47
+ it('returns a past timestamp for an expired token', () => {
48
+ expect(jwtExpiryMs(makeJwt({ exp: nowSec() - 300 }))).toBeLessThan(Date.now());
49
+ });
50
+
51
+ it('returns a future timestamp for a valid token', () => {
52
+ expect(jwtExpiryMs(makeJwt({ exp: nowSec() + 86400 }))).toBeGreaterThan(Date.now());
53
+ });
54
+
55
+ it('handles a token expiring in less than 60 minutes', () => {
56
+ const expSec = nowSec() + 30 * 60;
57
+ const result = jwtExpiryMs(makeJwt({ exp: expSec }))!;
58
+ const minsLeft = Math.floor((result - Date.now()) / 60_000);
59
+ expect(minsLeft).toBeGreaterThanOrEqual(29);
60
+ expect(minsLeft).toBeLessThanOrEqual(30);
61
+ });
62
+
63
+ it('ignores extra claims', () => {
64
+ const expSec = nowSec() + 3600;
65
+ expect(jwtExpiryMs(makeJwt({ sub: 'u', iat: nowSec(), exp: expSec, plan: 'free' }))).toBe(expSec * 1000);
66
+ });
67
+
68
+ it('handles padding-free base64url payloads', () => {
69
+ // base64url (no =) — Buffer.from handles this natively
70
+ const expSec = nowSec() + 7200;
71
+ expect(jwtExpiryMs(makeJwt({ exp: expSec, x: 'a' }))).toBe(expSec * 1000);
72
+ });
73
+ });
74
+
75
+ describe('checkJwt', () => {
76
+ it('throws InvalidRequest when jwt is empty', () => {
77
+ expect(() => checkJwt('')).toThrowError(
78
+ expect.objectContaining({ code: ErrorCode.InvalidRequest, message: expect.stringContaining('CACHLY_JWT env var not set') }),
79
+ );
80
+ });
81
+
82
+ it('throws with setup URL when jwt is missing', () => {
83
+ expect(() => checkJwt('')).toThrowError(
84
+ expect.objectContaining({ message: expect.stringContaining('cachly.dev/setup-ai') }),
85
+ );
86
+ });
87
+
88
+ it('throws InvalidRequest with expiry timestamp for an expired token', () => {
89
+ const expSec = nowSec() - 60;
90
+ const token = makeJwt({ exp: expSec });
91
+ expect(() => checkJwt(token)).toThrowError(
92
+ expect.objectContaining({
93
+ code: ErrorCode.InvalidRequest,
94
+ message: expect.stringContaining('expired at'),
95
+ }),
96
+ );
97
+ });
98
+
99
+ it('does not throw for a valid non-expired token', () => {
100
+ expect(() => checkJwt(makeJwt({ exp: nowSec() + 3600 }))).not.toThrow();
101
+ });
102
+
103
+ it('does not throw for a token with no exp claim', () => {
104
+ expect(() => checkJwt(makeJwt({ sub: 'user' }))).not.toThrow();
105
+ });
106
+ });
107
+
108
+ describe('handleApiError', () => {
109
+ it('throws InvalidRequest for 401 with the detail message', () => {
110
+ expect(() => handleApiError(401, 'token expired')).toThrowError(
111
+ expect.objectContaining({
112
+ code: ErrorCode.InvalidRequest,
113
+ message: expect.stringContaining('Authentication failed (401): token expired'),
114
+ }),
115
+ );
116
+ });
117
+
118
+ it('throws InvalidRequest for 403 with the detail message', () => {
119
+ expect(() => handleApiError(403, 'plan limit reached')).toThrowError(
120
+ expect.objectContaining({
121
+ code: ErrorCode.InvalidRequest,
122
+ message: expect.stringContaining('Access denied (403): plan limit reached'),
123
+ }),
124
+ );
125
+ });
126
+
127
+ it('throws InternalError for other status codes', () => {
128
+ expect(() => handleApiError(500, 'server crash')).toThrowError(
129
+ expect.objectContaining({
130
+ code: ErrorCode.InternalError,
131
+ message: expect.stringContaining('cachly API error 500: server crash'),
132
+ }),
133
+ );
134
+ });
135
+
136
+ it('includes the cachly.dev/setup-ai link for 401 errors', () => {
137
+ expect(() => handleApiError(401, 'Unauthorized')).toThrowError(
138
+ expect.objectContaining({ message: expect.stringContaining('cachly.dev/setup-ai') }),
139
+ );
140
+ });
141
+
142
+ it('includes account access hint for 403 errors', () => {
143
+ expect(() => handleApiError(403, 'Forbidden')).toThrowError(
144
+ expect.objectContaining({ message: expect.stringContaining('access to this resource') }),
145
+ );
146
+ });
147
+ });
@@ -0,0 +1,182 @@
1
+ /**
2
+ * E2E: Authenticated API endpoints
3
+ *
4
+ * Requires: E2E_JWT, E2E_INSTANCE_ID
5
+ * Run: npx vitest run src/__tests__/e2e/api-auth.test.ts
6
+ */
7
+
8
+ import { describe, it, expect, beforeAll } from 'vitest';
9
+ import { cfg, requireAuth, apiGet, apiPost } from './config.js';
10
+
11
+ beforeAll(() => requireAuth());
12
+
13
+ describe('Auth guard', () => {
14
+ it('GET /api/v1/instances without token returns 401', async () => {
15
+ const { status } = await apiGet('/api/v1/instances', '');
16
+ expect(status).toBe(401);
17
+ });
18
+
19
+ it('GET /api/v1/instances with invalid token returns 401', async () => {
20
+ const { status } = await apiGet('/api/v1/instances', 'not-a-real-token');
21
+ expect(status).toBe(401);
22
+ });
23
+
24
+ it('GET /api/v1/instances with valid token returns 200', async () => {
25
+ const { status } = await apiGet('/api/v1/instances');
26
+ expect(status).toBe(200);
27
+ });
28
+ });
29
+
30
+ describe('Instances API', () => {
31
+ // API returns { data: Instance[], count: number }
32
+ it('returns object with data array', async () => {
33
+ const { body } = await apiGet('/api/v1/instances');
34
+ const b = body as { data?: unknown[]; count?: number };
35
+ expect(Array.isArray(b.data)).toBe(true);
36
+ expect(typeof b.count).toBe('number');
37
+ });
38
+
39
+ it('each instance has required fields', async () => {
40
+ const { body } = await apiGet('/api/v1/instances');
41
+ const instances = ((body as { data?: Record<string, unknown>[] }).data) ?? [];
42
+ if (instances.length === 0) return; // fresh account — skip
43
+ for (const inst of instances) {
44
+ expect(typeof inst.id).toBe('string');
45
+ expect(typeof inst.name).toBe('string');
46
+ expect(typeof inst.status).toBe('string');
47
+ expect(typeof inst.tier).toBe('string');
48
+ }
49
+ });
50
+
51
+ it('test instance is present and running', async () => {
52
+ const { body } = await apiGet('/api/v1/instances');
53
+ const instances = ((body as { data?: Record<string, unknown>[] }).data) ?? [];
54
+ const found = instances.find((i) => i.id === cfg.instanceId);
55
+ expect(found).toBeDefined();
56
+ expect(found!.status).toBe('running');
57
+ });
58
+ });
59
+
60
+ describe('Single instance GET', () => {
61
+ it('returns 200 for test instance', async () => {
62
+ const { status } = await apiGet(`/api/v1/instances/${cfg.instanceId}`);
63
+ expect(status).toBe(200);
64
+ });
65
+
66
+ it('returns 404 for non-existent instance', async () => {
67
+ const { status } = await apiGet('/api/v1/instances/00000000-0000-0000-0000-000000000000');
68
+ expect([403, 404]).toContain(status);
69
+ });
70
+
71
+ it('instance body has id, status running, tier, name', async () => {
72
+ const { body } = await apiGet(`/api/v1/instances/${cfg.instanceId}`);
73
+ const inst = body as Record<string, unknown>;
74
+ expect(inst.id).toBe(cfg.instanceId);
75
+ expect(inst.status).toBe('running');
76
+ expect(typeof inst.tier).toBe('string');
77
+ expect(typeof inst.name).toBe('string');
78
+ });
79
+ });
80
+
81
+ describe('Connection endpoint', () => {
82
+ it('returns 200 with connection details for running instance', async () => {
83
+ const { status, body } = await apiGet(`/api/v1/instances/${cfg.instanceId}/connection`);
84
+ expect(status).toBe(200);
85
+ const conn = body as Record<string, unknown>;
86
+ expect(typeof conn.host).toBe('string');
87
+ expect(typeof conn.port).toBe('number');
88
+ expect(typeof conn.password).toBe('string');
89
+ expect((conn.host as string).length).toBeGreaterThan(3);
90
+ });
91
+
92
+ it('connection host is not localhost (prod/dev has real host)', async () => {
93
+ const { body } = await apiGet(`/api/v1/instances/${cfg.instanceId}/connection`);
94
+ const conn = body as Record<string, unknown>;
95
+ expect(String(conn.host)).not.toBe('localhost');
96
+ expect(String(conn.host)).not.toBe('127.0.0.1');
97
+ });
98
+
99
+ it('port is within valid Redis range', async () => {
100
+ const { body } = await apiGet(`/api/v1/instances/${cfg.instanceId}/connection`);
101
+ const conn = body as Record<string, unknown>;
102
+ expect(Number(conn.port)).toBeGreaterThan(1023);
103
+ expect(Number(conn.port)).toBeLessThan(65536);
104
+ });
105
+ });
106
+
107
+ describe('API keys', () => {
108
+ // API returns { keys: APIKey[], count: number }
109
+ it('GET /api/v1/api-keys returns 200 with keys array', async () => {
110
+ const { status, body } = await apiGet('/api/v1/api-keys');
111
+ expect(status).toBe(200);
112
+ const b = body as { keys?: unknown[]; count?: number };
113
+ expect(Array.isArray(b.keys)).toBe(true);
114
+ });
115
+
116
+ it('api-keys have id and instance_id fields', async () => {
117
+ const { body } = await apiGet('/api/v1/api-keys');
118
+ const keys = ((body as { keys?: Record<string, unknown>[] }).keys) ?? [];
119
+ if (keys.length === 0) return;
120
+ for (const k of keys) {
121
+ expect(typeof k.id).toBe('string');
122
+ expect(typeof k.instance_id).toBe('string');
123
+ }
124
+ });
125
+ });
126
+
127
+ describe('Brain Stats endpoint', () => {
128
+ it('GET /api/v1/instances/:id/brain-stats returns 200', async () => {
129
+ const { status } = await apiGet(`/api/v1/instances/${cfg.instanceId}/brain-stats`);
130
+ expect(status).toBe(200);
131
+ });
132
+
133
+ it('brain-stats body has brain_level and brain_level_name', async () => {
134
+ const { body } = await apiGet(`/api/v1/instances/${cfg.instanceId}/brain-stats`);
135
+ const stats = body as Record<string, unknown>;
136
+ expect(typeof stats.brain_level).toBe('number');
137
+ expect(stats.brain_level).toBeGreaterThanOrEqual(1);
138
+ expect(typeof stats.brain_level_name).toBe('string');
139
+ expect(['Apprentice', 'Explorer', 'Expert', 'Architect', 'Oracle']).toContain(stats.brain_level_name);
140
+ });
141
+
142
+ it('brain-stats has hours_saved and brain_recall_count', async () => {
143
+ const { body } = await apiGet(`/api/v1/instances/${cfg.instanceId}/brain-stats`);
144
+ const stats = body as Record<string, unknown>;
145
+ expect(typeof stats.hours_saved).toBe('number');
146
+ expect(stats.hours_saved).toBeGreaterThanOrEqual(0);
147
+ expect(typeof stats.brain_recall_count).toBe('number');
148
+ expect(stats.brain_recall_count).toBeGreaterThanOrEqual(0);
149
+ });
150
+
151
+ it('brain-stats has standard search telemetry fields', async () => {
152
+ const { body } = await apiGet(`/api/v1/instances/${cfg.instanceId}/brain-stats`);
153
+ const stats = body as Record<string, unknown>;
154
+ expect(typeof stats.total_searches).toBe('number');
155
+ expect(typeof stats.avg_latency_ms).toBe('number');
156
+ expect(Array.isArray(stats.top_queries)).toBe(true);
157
+ });
158
+ });
159
+
160
+ describe('Provisioning status messages', () => {
161
+ it('non-existent instance connection returns 4xx, never 500', async () => {
162
+ const { status } = await apiGet('/api/v1/instances/00000000-0000-0000-0000-000000000001/connection');
163
+ expect([400, 403, 404, 422]).toContain(status);
164
+ });
165
+ });
166
+
167
+ describe('Auto-provision endpoint', () => {
168
+ it('POST /api/v1/instances/auto returns 200 or 201', async () => {
169
+ const { status } = await apiPost('/api/v1/instances/auto', {});
170
+ expect([200, 201]).toContain(status);
171
+ });
172
+
173
+ it('returns existing running instance without creating a new one', async () => {
174
+ const { body } = await apiPost('/api/v1/instances/auto', {});
175
+ const b = body as { instance?: { id: string; status: string }; created?: boolean };
176
+ if (b.instance) {
177
+ // Existing instance returned
178
+ expect(typeof b.instance.id).toBe('string');
179
+ expect(b.created).toBe(false);
180
+ }
181
+ });
182
+ });