@cachly-dev/mcp-server 0.10.26 → 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.
- 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 +142 -112
- 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/brain.js +119 -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 -14
- 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/brain.ts +118 -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
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. 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: "
|
|
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
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E: API health & public endpoints
|
|
3
|
+
*
|
|
4
|
+
* These tests require NO credentials — they hit publicly accessible endpoints.
|
|
5
|
+
* Run: E2E_API_URL=https://api.dev.cachly.dev npx vitest run src/__tests__/e2e/api-health.test.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
9
|
+
import { cfg } from './config.js';
|
|
10
|
+
|
|
11
|
+
describe('API health (no auth required)', () => {
|
|
12
|
+
it('GET /health returns 200', async () => {
|
|
13
|
+
const res = await fetch(`${cfg.apiUrl}/health`);
|
|
14
|
+
expect(res.status).toBe(200);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('GET /health body contains status ok', async () => {
|
|
18
|
+
const res = await fetch(`${cfg.apiUrl}/health`);
|
|
19
|
+
const body = await res.json() as Record<string, unknown>;
|
|
20
|
+
expect(body.status ?? body.ok ?? body.alive).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('unknown route returns 404, not 500', async () => {
|
|
24
|
+
const res = await fetch(`${cfg.apiUrl}/api/v1/does-not-exist-xyz`);
|
|
25
|
+
expect(res.status).toBe(404);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('Device flow init (no auth required)', () => {
|
|
30
|
+
it('GET /api/v1/auth/device/code returns device_code and user_code', async () => {
|
|
31
|
+
const res = await fetch(`${cfg.apiUrl}/api/v1/auth/device/code`);
|
|
32
|
+
expect(res.status).toBe(200);
|
|
33
|
+
const body = await res.json() as Record<string, unknown>;
|
|
34
|
+
expect(typeof body.device_code).toBe('string');
|
|
35
|
+
expect(typeof body.user_code).toBe('string');
|
|
36
|
+
expect(typeof body.verification_uri).toBe('string');
|
|
37
|
+
expect(body.verification_uri).toContain('cachly.dev');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('device_code is at least 20 chars (not truncated)', async () => {
|
|
41
|
+
const res = await fetch(`${cfg.apiUrl}/api/v1/auth/device/code`);
|
|
42
|
+
const body = await res.json() as Record<string, unknown>;
|
|
43
|
+
expect((body.device_code as string).length).toBeGreaterThanOrEqual(20);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('expires_in is a positive number', async () => {
|
|
47
|
+
const res = await fetch(`${cfg.apiUrl}/api/v1/auth/device/code`);
|
|
48
|
+
const body = await res.json() as Record<string, unknown>;
|
|
49
|
+
expect(Number(body.expires_in)).toBeGreaterThan(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('Badge endpoint (public, no auth)', () => {
|
|
54
|
+
it('returns 400 or 404 for an invalid instance id', async () => {
|
|
55
|
+
const res = await fetch(`${cfg.apiUrl}/api/v1/badge/not-a-real-uuid`);
|
|
56
|
+
expect([400, 404]).toContain(res.status);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns badge JSON for a valid instance id', async () => {
|
|
60
|
+
if (!cfg.instanceId) return; // skip if not configured
|
|
61
|
+
const res = await fetch(`${cfg.apiUrl}/api/v1/badge/${cfg.instanceId}`);
|
|
62
|
+
expect(res.status).toBe(200);
|
|
63
|
+
const body = await res.json() as Record<string, unknown>;
|
|
64
|
+
expect(typeof body.schemaVersion).toBe('number');
|
|
65
|
+
expect(typeof body.label).toBe('string');
|
|
66
|
+
expect(typeof body.message).toBe('string');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('badge message contains lesson count (numeric string)', async () => {
|
|
70
|
+
if (!cfg.instanceId) return;
|
|
71
|
+
const res = await fetch(`${cfg.apiUrl}/api/v1/badge/${cfg.instanceId}`);
|
|
72
|
+
const body = await res.json() as Record<string, unknown>;
|
|
73
|
+
expect((body.message as string)).toMatch(/\d+/);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('Telemetry endpoint (public)', () => {
|
|
78
|
+
it('POST /api/v1/telemetry/mcp accepts anonymous event', async () => {
|
|
79
|
+
const res = await fetch(`${cfg.apiUrl}/api/v1/telemetry/mcp`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ event: 'e2e_test_ping', version: '0.0.0', editor: 'test', source: 'e2e' }),
|
|
83
|
+
});
|
|
84
|
+
expect([200, 202, 204]).toContain(res.status);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('POST /api/v1/telemetry/mcp accepts jwt-attributed event', async () => {
|
|
88
|
+
if (!cfg.jwt) return;
|
|
89
|
+
const res = await fetch(`${cfg.apiUrl}/api/v1/telemetry/mcp`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
event: 'e2e_test_ping_authed',
|
|
94
|
+
version: '0.0.0',
|
|
95
|
+
editor: 'test',
|
|
96
|
+
source: 'e2e',
|
|
97
|
+
jwt: cfg.jwt,
|
|
98
|
+
instance_id: cfg.instanceId,
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
expect([200, 202, 204]).toContain(res.status);
|
|
102
|
+
});
|
|
103
|
+
});
|