@cachly-dev/mcp-server 0.10.27 → 0.10.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.test.example +23 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +50 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +25 -0
- package/.github/workflows/publish.yml +72 -0
- package/CHANGELOG.md +252 -0
- package/LAUNCH_TEXTS.md +182 -0
- package/LICENSE +202 -0
- package/README.md +145 -115
- package/dist/__tests__/auth.test.d.ts +1 -0
- package/dist/__tests__/auth.test.js +107 -0
- package/dist/__tests__/auth.test.js.map +1 -0
- package/dist/__tests__/e2e/api-auth.test.d.ts +7 -0
- package/dist/__tests__/e2e/api-auth.test.js +162 -0
- package/dist/__tests__/e2e/api-auth.test.js.map +1 -0
- package/dist/__tests__/e2e/api-health.test.d.ts +7 -0
- package/dist/__tests__/e2e/api-health.test.js +95 -0
- package/dist/__tests__/e2e/api-health.test.js.map +1 -0
- package/dist/__tests__/e2e/cli.test.d.ts +10 -0
- package/dist/__tests__/e2e/cli.test.js +204 -0
- package/dist/__tests__/e2e/cli.test.js.map +1 -0
- package/dist/__tests__/e2e/config.d.ts +35 -0
- package/dist/__tests__/e2e/config.js +53 -0
- package/dist/__tests__/e2e/config.js.map +1 -0
- package/dist/__tests__/e2e/funnel.test.d.ts +13 -0
- package/dist/__tests__/e2e/funnel.test.js +90 -0
- package/dist/__tests__/e2e/funnel.test.js.map +1 -0
- package/dist/__tests__/e2e/mcp-tools.test.d.ts +7 -0
- package/dist/__tests__/e2e/mcp-tools.test.js +129 -0
- package/dist/__tests__/e2e/mcp-tools.test.js.map +1 -0
- package/dist/__tests__/e2e/provisioning.test.d.ts +15 -0
- package/dist/__tests__/e2e/provisioning.test.js +123 -0
- package/dist/__tests__/e2e/provisioning.test.js.map +1 -0
- package/dist/handlers/advanced.js +38 -227
- package/dist/handlers/advanced.js.map +1 -1
- package/dist/handlers/brain.js +146 -82
- package/dist/handlers/brain.js.map +1 -1
- package/dist/handlers/fedbrain.js +93 -44
- package/dist/handlers/fedbrain.js.map +1 -1
- package/dist/handlers/roadmap.js +8 -2
- package/dist/handlers/roadmap.js.map +1 -1
- package/dist/handlers/syndicate.js +15 -7
- package/dist/handlers/syndicate.js.map +1 -1
- package/dist/handlers/team.js +62 -50
- package/dist/handlers/team.js.map +1 -1
- package/dist/index.js +1264 -234
- package/dist/index.js.map +1 -1
- package/dist/tools.d.ts +3 -54
- package/dist/tools.js +8 -62
- package/dist/tools.js.map +1 -1
- package/eslint.config.js +16 -0
- package/glama.json +36 -0
- package/llms.txt +155 -0
- package/package.json +38 -32
- package/scripts/e2e-run.sh +160 -0
- package/scripts/postinstall.js +12 -12
- package/server.json +3 -9
- package/smithery.yaml +15 -8
- package/src/__tests__/auth.test.ts +147 -0
- package/src/__tests__/e2e/api-auth.test.ts +182 -0
- package/src/__tests__/e2e/api-health.test.ts +103 -0
- package/src/__tests__/e2e/cli.test.ts +228 -0
- package/src/__tests__/e2e/config.ts +53 -0
- package/src/__tests__/e2e/funnel.test.ts +99 -0
- package/src/__tests__/e2e/mcp-tools.test.ts +191 -0
- package/src/__tests__/e2e/provisioning.test.ts +128 -0
- package/src/handlers/advanced.ts +37 -217
- package/src/handlers/brain.ts +143 -78
- package/src/handlers/fedbrain.ts +88 -38
- package/src/handlers/roadmap.ts +7 -2
- package/src/handlers/syndicate.ts +13 -7
- package/src/handlers/team.ts +57 -33
- package/src/index.ts +1312 -257
- package/src/tools.ts +8 -66
- package/vitest.config.ts +12 -0
- package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
- package/dist/__tests__/brain-predict-failures.test.js +0 -339
- package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
- 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
|
package/scripts/postinstall.js
CHANGED
|
@@ -2,28 +2,28 @@
|
|
|
2
2
|
// Shown once after: npm install @cachly-dev/mcp-server
|
|
3
3
|
|
|
4
4
|
const c = {
|
|
5
|
-
reset:
|
|
6
|
-
bold:
|
|
5
|
+
reset: "\x1b[0m",
|
|
6
|
+
bold: "\x1b[1m",
|
|
7
7
|
violet: "\x1b[35m",
|
|
8
|
-
green:
|
|
9
|
-
cyan:
|
|
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}🧠
|
|
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}
|
|
20
|
+
${c.bold}One command sets everything up:${c.reset}
|
|
21
21
|
|
|
22
|
-
${c.
|
|
23
|
-
${c.cyan}npx @cachly-dev/init${c.reset}
|
|
22
|
+
${c.cyan}npx @cachly-dev/mcp-server@latest setup${c.reset}
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
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": "
|
|
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.
|
|
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.
|
|
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. 84 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: "
|
|
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: "
|
|
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: "
|
|
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
|
+
});
|