@openephemeris/mcp-server 3.1.0 → 3.2.2
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/README.md +51 -22
- package/config/dev-allowlist.json +1262 -1318
- package/dist/index.js +0 -0
- package/dist/scripts/dev-allowlist.d.ts +1 -0
- package/dist/scripts/dev-allowlist.js +287 -0
- package/dist/scripts/pack-audit.d.ts +1 -0
- package/dist/scripts/pack-audit.js +45 -0
- package/dist/scripts/schema-packs.d.ts +1 -0
- package/dist/scripts/schema-packs.js +150 -0
- package/dist/scripts/smoke-dev-profile.d.ts +1 -0
- package/dist/scripts/smoke-dev-profile.js +25 -0
- package/dist/scripts/sync-readme.d.ts +1 -0
- package/dist/scripts/sync-readme.js +141 -0
- package/dist/scripts/test-client.d.ts +1 -0
- package/dist/scripts/test-client.js +69 -0
- package/dist/scripts/test-sse-client.d.ts +1 -0
- package/dist/scripts/test-sse-client.js +221 -0
- package/dist/src/auth/credentials.d.ts +65 -0
- package/dist/src/auth/credentials.js +200 -0
- package/dist/src/auth/device-auth.d.ts +56 -0
- package/dist/src/auth/device-auth.js +147 -0
- package/dist/src/backend/client.d.ts +61 -0
- package/dist/src/backend/client.js +335 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +98 -0
- package/dist/src/schema-packs/llm.d.ts +105 -0
- package/dist/src/schema-packs/llm.js +429 -0
- package/dist/src/server-sse.d.ts +1 -0
- package/dist/src/server-sse.js +264 -0
- package/dist/src/tools/auth.d.ts +1 -0
- package/dist/src/tools/auth.js +202 -0
- package/dist/src/tools/dev.d.ts +1 -0
- package/dist/src/tools/dev.js +195 -0
- package/dist/src/tools/index.d.ts +33 -0
- package/dist/src/tools/index.js +56 -0
- package/dist/src/tools/specialized/eclipse.d.ts +1 -0
- package/dist/src/tools/specialized/eclipse.js +53 -0
- package/dist/src/tools/specialized/electional.d.ts +1 -0
- package/dist/src/tools/specialized/electional.js +80 -0
- package/dist/src/tools/specialized/human_design.d.ts +1 -0
- package/dist/src/tools/specialized/human_design.js +54 -0
- package/dist/src/tools/specialized/moon.d.ts +1 -0
- package/dist/src/tools/specialized/moon.js +51 -0
- package/dist/src/tools/specialized/natal.d.ts +1 -0
- package/dist/src/tools/specialized/natal.js +80 -0
- package/dist/src/tools/specialized/relocation.d.ts +1 -0
- package/dist/src/tools/specialized/relocation.js +76 -0
- package/dist/src/tools/specialized/synastry.d.ts +1 -0
- package/dist/src/tools/specialized/synastry.js +73 -0
- package/dist/src/tools/specialized/transits.d.ts +1 -0
- package/dist/src/tools/specialized/transits.js +87 -0
- package/dist/test/allowlist-and-tools.test.d.ts +1 -0
- package/dist/test/allowlist-and-tools.test.js +96 -0
- package/dist/test/backend-client.test.d.ts +1 -0
- package/dist/test/backend-client.test.js +284 -0
- package/dist/test/credentials.test.d.ts +1 -0
- package/dist/test/credentials.test.js +143 -0
- package/package.json +27 -18
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { registerTool, validateRequired } from "../index.js";
|
|
2
|
+
import { backendClient } from "../../backend/client.js";
|
|
3
|
+
registerTool({
|
|
4
|
+
name: "ephemeris_transits",
|
|
5
|
+
description: "Search for astrological transit events affecting a natal chart over a date range. " +
|
|
6
|
+
"Returns a list of exact transit moments — when transiting planets form specified aspects " +
|
|
7
|
+
"to natal planet positions. Ideal for generating horoscope timelines, event forecasting, or " +
|
|
8
|
+
"finding optimal timing windows.\n\n" +
|
|
9
|
+
"CREDIT COST: 5 credits per call.\n\n" +
|
|
10
|
+
"EXAMPLE: Find all Saturn transits to the natal Sun/Moon for the next 6 months:\n" +
|
|
11
|
+
" natal_datetime='1990-04-15T14:30:00', natal_latitude=41.8781, natal_longitude=-87.6298,\n" +
|
|
12
|
+
" start_date='2026-01-01', end_date='2026-06-30', transiting_planets=['saturn']",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
natal_datetime: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "ISO 8601 birth datetime for the natal chart.",
|
|
19
|
+
},
|
|
20
|
+
natal_latitude: {
|
|
21
|
+
type: "number",
|
|
22
|
+
description: "Latitude of birth location in decimal degrees.",
|
|
23
|
+
},
|
|
24
|
+
natal_longitude: {
|
|
25
|
+
type: "number",
|
|
26
|
+
description: "Longitude of birth location in decimal degrees.",
|
|
27
|
+
},
|
|
28
|
+
start_date: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Start of the transit search window, ISO 8601 date or datetime (e.g. '2026-01-01').",
|
|
31
|
+
},
|
|
32
|
+
end_date: {
|
|
33
|
+
type: "string",
|
|
34
|
+
description: "End of the transit search window, ISO 8601 date or datetime (e.g. '2026-06-30').",
|
|
35
|
+
},
|
|
36
|
+
transiting_planets: {
|
|
37
|
+
type: "array",
|
|
38
|
+
items: { type: "string" },
|
|
39
|
+
description: "List of transiting planet IDs to search. E.g. ['saturn', 'jupiter', 'uranus', 'pluto']. " +
|
|
40
|
+
"Omit to search all outer planets.",
|
|
41
|
+
},
|
|
42
|
+
natal_points: {
|
|
43
|
+
type: "array",
|
|
44
|
+
items: { type: "string" },
|
|
45
|
+
description: "Natal point IDs to receive transits. E.g. ['sun', 'moon', 'asc', 'mc']. Omit for all core points.",
|
|
46
|
+
},
|
|
47
|
+
aspects: {
|
|
48
|
+
type: "array",
|
|
49
|
+
items: { type: "string", enum: ["conjunction", "opposition", "trine", "square", "sextile"] },
|
|
50
|
+
description: "Aspect types to include in the search. Defaults to all major aspects.",
|
|
51
|
+
},
|
|
52
|
+
orb: {
|
|
53
|
+
type: "number",
|
|
54
|
+
description: "Maximum orb in degrees (default: 1.0).",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
required: ["natal_datetime", "natal_latitude", "natal_longitude", "start_date", "end_date"],
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
},
|
|
60
|
+
handler: async (args) => {
|
|
61
|
+
validateRequired(args, ["natal_datetime", "natal_latitude", "natal_longitude", "start_date", "end_date"]);
|
|
62
|
+
let startStr = args.start_date;
|
|
63
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(startStr)) {
|
|
64
|
+
startStr += "T00:00:00Z";
|
|
65
|
+
}
|
|
66
|
+
let endStr = args.end_date;
|
|
67
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(endStr)) {
|
|
68
|
+
endStr += "T23:59:59Z";
|
|
69
|
+
}
|
|
70
|
+
const body = {
|
|
71
|
+
start_date: startStr,
|
|
72
|
+
end_date: endStr,
|
|
73
|
+
};
|
|
74
|
+
if (args.transiting_planets)
|
|
75
|
+
body.planet_names = args.transiting_planets;
|
|
76
|
+
const searchCriteria = {};
|
|
77
|
+
if (args.natal_points)
|
|
78
|
+
searchCriteria.natal_points = args.natal_points;
|
|
79
|
+
if (args.aspects)
|
|
80
|
+
searchCriteria.aspects = args.aspects;
|
|
81
|
+
if (args.orb != null)
|
|
82
|
+
searchCriteria.orb = args.orb;
|
|
83
|
+
if (Object.keys(searchCriteria).length > 0)
|
|
84
|
+
body.search_criteria = searchCriteria;
|
|
85
|
+
return await backendClient.request("POST", "/predictive/transits/search", { data: body });
|
|
86
|
+
},
|
|
87
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
2
|
+
import { toolRegistry, initTools } from "../src/tools/index.js";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const allowlistPath = path.resolve(here, "../config/dev-allowlist.json");
|
|
8
|
+
function isDeniedByPrefix(pathname, prefixes) {
|
|
9
|
+
return prefixes.some((p) => pathname.startsWith(p));
|
|
10
|
+
}
|
|
11
|
+
function isAllowedOperation(method, pathname, allow) {
|
|
12
|
+
return allow.some((e) => e.method.toUpperCase() === method.toUpperCase() && e.path === pathname);
|
|
13
|
+
}
|
|
14
|
+
describe("dev-allowlist.json", () => {
|
|
15
|
+
const raw = fs.readFileSync(allowlistPath, "utf-8");
|
|
16
|
+
const allowlist = JSON.parse(raw);
|
|
17
|
+
it("has valid schema identifier", () => {
|
|
18
|
+
expect(allowlist.schema).toBe("astromcp-dev-allowlist-v1");
|
|
19
|
+
});
|
|
20
|
+
it("has at least 60 allowlisted operations", () => {
|
|
21
|
+
// 68 entries from Go openapi.json; this guard catches accidental mass-deletion
|
|
22
|
+
expect(allowlist.allow.length).toBeGreaterThanOrEqual(60);
|
|
23
|
+
});
|
|
24
|
+
it("all allow entries have valid method and path starting with /", () => {
|
|
25
|
+
const validMethods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
|
26
|
+
for (const entry of allowlist.allow) {
|
|
27
|
+
expect(validMethods.has(entry.method), `Invalid method: ${entry.method}`).toBe(true);
|
|
28
|
+
expect(entry.path.startsWith("/"), `Path must start with /: ${entry.path}`).toBe(true);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
it("deny prefixes block auth/billing/admin paths", () => {
|
|
32
|
+
const denyPrefixes = allowlist.deny?.path_prefixes ?? [];
|
|
33
|
+
const blockedPaths = ["/auth/login", "/billing/me", "/admin/users", "/api-keys"];
|
|
34
|
+
for (const blocked of blockedPaths) {
|
|
35
|
+
expect(isDeniedByPrefix(blocked, denyPrefixes), `Expected ${blocked} to be blocked by deny prefixes`).toBe(true);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it("no allow entry violates deny prefixes", () => {
|
|
39
|
+
const denyPrefixes = allowlist.deny?.path_prefixes ?? [];
|
|
40
|
+
for (const entry of allowlist.allow) {
|
|
41
|
+
expect(isDeniedByPrefix(entry.path, denyPrefixes), `Allow entry ${entry.method} ${entry.path} violates a deny prefix`).toBe(false);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
it("isAllowedOperation works for known Go endpoints", () => {
|
|
45
|
+
expect(isAllowedOperation("GET", "/catalogs/bodies", allowlist.allow)).toBe(true);
|
|
46
|
+
expect(isAllowedOperation("GET", "/electional/find-window", allowlist.allow)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it("phantom paths are NOT in the allowlist (Go API only)", () => {
|
|
49
|
+
// These paths don't exist in the Go openapi.json — they were legacy Python routes
|
|
50
|
+
expect(isAllowedOperation("GET", "/agro/daily", allowlist.allow)).toBe(false);
|
|
51
|
+
expect(isAllowedOperation("GET", "/agro/calendar", allowlist.allow)).toBe(false);
|
|
52
|
+
expect(isAllowedOperation("GET", "/batch/position", allowlist.allow)).toBe(false);
|
|
53
|
+
expect(isAllowedOperation("GET", "/houses", allowlist.allow)).toBe(false);
|
|
54
|
+
expect(isAllowedOperation("GET", "/lunar/solunar", allowlist.allow)).toBe(false);
|
|
55
|
+
expect(isAllowedOperation("GET", "/lunar/void-of-course", allowlist.allow)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
it("isAllowedOperation blocks denied paths", () => {
|
|
58
|
+
expect(isAllowedOperation("POST", "/auth/login", allowlist.allow)).toBe(false);
|
|
59
|
+
expect(isAllowedOperation("GET", "/admin/users", allowlist.allow)).toBe(false);
|
|
60
|
+
expect(isAllowedOperation("DELETE", "/api-keys/abc", allowlist.allow)).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe("toolRegistry", () => {
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
await initTools("dev");
|
|
66
|
+
});
|
|
67
|
+
it("registers all expected tools", () => {
|
|
68
|
+
const expectedTools = [
|
|
69
|
+
"auth_login",
|
|
70
|
+
"auth_status",
|
|
71
|
+
"auth_logout",
|
|
72
|
+
"dev_call",
|
|
73
|
+
"dev_list_allowed",
|
|
74
|
+
"ephemeris_natal_chart",
|
|
75
|
+
"ephemeris_transits",
|
|
76
|
+
"ephemeris_moon_phase",
|
|
77
|
+
"ephemeris_next_eclipse",
|
|
78
|
+
"ephemeris_synastry",
|
|
79
|
+
"ephemeris_relocation",
|
|
80
|
+
"ephemeris_electional",
|
|
81
|
+
"human_design_chart",
|
|
82
|
+
];
|
|
83
|
+
for (const name of expectedTools) {
|
|
84
|
+
expect(toolRegistry[name], `Missing tool: ${name}`).toBeDefined();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
it("every registered tool has required fields", () => {
|
|
88
|
+
for (const [name, tool] of Object.entries(toolRegistry)) {
|
|
89
|
+
expect(typeof tool.name, `${name}: name must be string`).toBe("string");
|
|
90
|
+
expect(typeof tool.description, `${name}: description must be string`).toBe("string");
|
|
91
|
+
expect(tool.description.length, `${name}: description must not be empty`).toBeGreaterThan(10);
|
|
92
|
+
expect(typeof tool.handler, `${name}: handler must be function`).toBe("function");
|
|
93
|
+
expect(tool.inputSchema, `${name}: inputSchema must be defined`).toBeDefined();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { BackendClient, BackendError } from "../src/backend/client.js";
|
|
4
|
+
async function startJsonServer(handler) {
|
|
5
|
+
const server = http.createServer(handler);
|
|
6
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
7
|
+
const { port } = server.address();
|
|
8
|
+
return {
|
|
9
|
+
baseURL: `http://127.0.0.1:${port}`,
|
|
10
|
+
close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
describe("BackendClient", () => {
|
|
14
|
+
async function getErrorMessage(run) {
|
|
15
|
+
try {
|
|
16
|
+
await run();
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
return error instanceof Error ? error.message : String(error);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
it("injects service key with highest priority", async () => {
|
|
24
|
+
const srv = await startJsonServer((req, res) => {
|
|
25
|
+
res.setHeader("Content-Type", "application/json");
|
|
26
|
+
res.end(JSON.stringify({ headers: req.headers }));
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
const client = new BackendClient({
|
|
30
|
+
baseURL: srv.baseURL,
|
|
31
|
+
serviceKey: "svc-key",
|
|
32
|
+
apiKey: "api-key",
|
|
33
|
+
jwt: "jwt-token",
|
|
34
|
+
});
|
|
35
|
+
const out = await client.get("/headers");
|
|
36
|
+
expect(out.headers["x-service-key"]).toBe("svc-key");
|
|
37
|
+
expect(out.headers["x-api-key"]).toBeUndefined();
|
|
38
|
+
expect(out.headers.authorization).toBeUndefined();
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await srv.close();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
it("respects explicit request auth headers over injected credentials", async () => {
|
|
45
|
+
const srv = await startJsonServer((req, res) => {
|
|
46
|
+
res.setHeader("Content-Type", "application/json");
|
|
47
|
+
res.end(JSON.stringify({ headers: req.headers }));
|
|
48
|
+
});
|
|
49
|
+
try {
|
|
50
|
+
const client = new BackendClient({
|
|
51
|
+
baseURL: srv.baseURL,
|
|
52
|
+
serviceKey: "svc-key",
|
|
53
|
+
});
|
|
54
|
+
const out = await client.request("GET", "/headers", {
|
|
55
|
+
headers: {
|
|
56
|
+
Authorization: "Bearer explicit-token",
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
expect(out.headers.authorization).toBe("Bearer explicit-token");
|
|
60
|
+
expect(out.headers["x-service-key"]).toBeUndefined();
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await srv.close();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
it("maps backend 404 into an MCP-friendly error", async () => {
|
|
67
|
+
const srv = await startJsonServer((_req, res) => {
|
|
68
|
+
res.statusCode = 404;
|
|
69
|
+
res.setHeader("Content-Type", "application/json");
|
|
70
|
+
res.end(JSON.stringify({ message: "missing resource" }));
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
const client = new BackendClient({
|
|
74
|
+
baseURL: srv.baseURL,
|
|
75
|
+
});
|
|
76
|
+
await expect(client.get("/missing")).rejects.toThrow("Resource not found: missing resource");
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await srv.close();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
it("maps backend 401 into signup and API key guidance", async () => {
|
|
83
|
+
const srv = await startJsonServer((_req, res) => {
|
|
84
|
+
res.statusCode = 401;
|
|
85
|
+
res.setHeader("Content-Type", "application/json");
|
|
86
|
+
res.end(JSON.stringify({
|
|
87
|
+
detail: "Missing credentials. Provide X-API-Key or Authorization bearer token.",
|
|
88
|
+
}));
|
|
89
|
+
});
|
|
90
|
+
try {
|
|
91
|
+
const client = new BackendClient({
|
|
92
|
+
baseURL: srv.baseURL,
|
|
93
|
+
});
|
|
94
|
+
const message = await getErrorMessage(() => client.get("/secure"));
|
|
95
|
+
expect(message).toMatch(/Account connection required|Authentication required:/);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await srv.close();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
it("maps backend 402 into quota and upgrade guidance", async () => {
|
|
102
|
+
const srv = await startJsonServer((_req, res) => {
|
|
103
|
+
res.statusCode = 402;
|
|
104
|
+
res.setHeader("Content-Type", "application/json");
|
|
105
|
+
res.end(JSON.stringify({
|
|
106
|
+
detail: "Monthly quota exceeded for this account.",
|
|
107
|
+
}));
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
const client = new BackendClient({
|
|
111
|
+
baseURL: srv.baseURL,
|
|
112
|
+
});
|
|
113
|
+
const message = await getErrorMessage(() => client.get("/quota"));
|
|
114
|
+
expect(message).toContain("Usage quota exceeded:");
|
|
115
|
+
expect(message).toContain("https://openephemeris.com/pay");
|
|
116
|
+
expect(message).toContain("https://openephemeris.com/dashboard?tab=account");
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
await srv.close();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
it("maps backend 403 into tier-upgrade guidance", async () => {
|
|
123
|
+
const srv = await startJsonServer((_req, res) => {
|
|
124
|
+
res.statusCode = 403;
|
|
125
|
+
res.setHeader("Content-Type", "application/json");
|
|
126
|
+
res.end(JSON.stringify({
|
|
127
|
+
detail: "This endpoint requires at least the Developer tier.",
|
|
128
|
+
}));
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
const client = new BackendClient({
|
|
132
|
+
baseURL: srv.baseURL,
|
|
133
|
+
});
|
|
134
|
+
const message = await getErrorMessage(() => client.get("/tier-gated"));
|
|
135
|
+
expect(message).toContain("Access blocked by plan or policy:");
|
|
136
|
+
expect(message).toContain("https://openephemeris.com/pay");
|
|
137
|
+
expect(message).toContain("https://openephemeris.com/dashboard?tab=account");
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await srv.close();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
it("maps backend 429 into dashboard usage guidance", async () => {
|
|
144
|
+
const srv = await startJsonServer((_req, res) => {
|
|
145
|
+
res.statusCode = 429;
|
|
146
|
+
res.setHeader("Content-Type", "application/json");
|
|
147
|
+
res.end(JSON.stringify({
|
|
148
|
+
detail: "Rate limit exceeded for authenticated user tier.",
|
|
149
|
+
}));
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
const client = new BackendClient({
|
|
153
|
+
baseURL: srv.baseURL,
|
|
154
|
+
});
|
|
155
|
+
const message = await getErrorMessage(() => client.get("/rate-limit"));
|
|
156
|
+
expect(message).toContain("Rate limit exceeded.");
|
|
157
|
+
expect(message).toContain("https://openephemeris.com/dashboard?tab=account");
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
await srv.close();
|
|
161
|
+
}
|
|
162
|
+
}, 15_000); // Extended timeout: 3 retries with backoff + jitter
|
|
163
|
+
it("retries on 503 and succeeds on second attempt", async () => {
|
|
164
|
+
let attempt = 0;
|
|
165
|
+
const srv = await startJsonServer((_req, res) => {
|
|
166
|
+
attempt++;
|
|
167
|
+
if (attempt === 1) {
|
|
168
|
+
res.statusCode = 503;
|
|
169
|
+
res.setHeader("Content-Type", "application/json");
|
|
170
|
+
res.end(JSON.stringify({ detail: "Service unavailable" }));
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
res.statusCode = 200;
|
|
174
|
+
res.setHeader("Content-Type", "application/json");
|
|
175
|
+
res.end(JSON.stringify({ ok: true }));
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
const client = new BackendClient({ baseURL: srv.baseURL });
|
|
180
|
+
const result = await client.get("/transient");
|
|
181
|
+
expect(result.ok).toBe(true);
|
|
182
|
+
expect(attempt).toBe(2);
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await srv.close();
|
|
186
|
+
}
|
|
187
|
+
}, 10_000);
|
|
188
|
+
it("BackendError exposes status, code, and retryable", async () => {
|
|
189
|
+
const srv = await startJsonServer((_req, res) => {
|
|
190
|
+
res.statusCode = 403;
|
|
191
|
+
res.setHeader("Content-Type", "application/json");
|
|
192
|
+
res.end(JSON.stringify({ detail: "Tier upgrade required" }));
|
|
193
|
+
});
|
|
194
|
+
try {
|
|
195
|
+
const client = new BackendClient({ baseURL: srv.baseURL });
|
|
196
|
+
try {
|
|
197
|
+
await client.get("/gated");
|
|
198
|
+
expect.unreachable("Should have thrown");
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
expect(err).toBeInstanceOf(BackendError);
|
|
202
|
+
const be = err;
|
|
203
|
+
expect(be.status).toBe(403);
|
|
204
|
+
expect(be.code).toBe("tier_required");
|
|
205
|
+
expect(be.retryable).toBe(false);
|
|
206
|
+
expect(be.upgradeUrl).toContain("openephemeris.com");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
await srv.close();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
it("returns a base64 payload envelope for chart-wheel binary endpoints", async () => {
|
|
214
|
+
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02]);
|
|
215
|
+
const srv = await startJsonServer((_req, res) => {
|
|
216
|
+
res.statusCode = 200;
|
|
217
|
+
res.setHeader("Content-Type", "image/png");
|
|
218
|
+
res.end(pngBytes);
|
|
219
|
+
});
|
|
220
|
+
try {
|
|
221
|
+
const client = new BackendClient({
|
|
222
|
+
baseURL: srv.baseURL,
|
|
223
|
+
});
|
|
224
|
+
const payload = await client.request("POST", "/visualization/chart-wheel", {
|
|
225
|
+
params: { format: "png" },
|
|
226
|
+
data: {},
|
|
227
|
+
});
|
|
228
|
+
expect(payload.content_type).toBe("image/png");
|
|
229
|
+
expect(payload.content_length).toBe(pngBytes.length);
|
|
230
|
+
expect(payload.encoding).toBe("base64");
|
|
231
|
+
expect(Buffer.from(payload.data_base64, "base64").equals(pngBytes)).toBe(true);
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
await srv.close();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
it("treats chart-wheel as binary without explicit format query", async () => {
|
|
238
|
+
const svg = "<svg></svg>";
|
|
239
|
+
const srv = await startJsonServer((_req, res) => {
|
|
240
|
+
res.statusCode = 200;
|
|
241
|
+
res.setHeader("Content-Type", "image/svg+xml");
|
|
242
|
+
res.end(svg);
|
|
243
|
+
});
|
|
244
|
+
try {
|
|
245
|
+
const client = new BackendClient({
|
|
246
|
+
baseURL: srv.baseURL,
|
|
247
|
+
});
|
|
248
|
+
const payload = await client.request("POST", "/visualization/chart-wheel", {
|
|
249
|
+
data: {},
|
|
250
|
+
});
|
|
251
|
+
expect(payload.content_type).toBe("image/svg+xml");
|
|
252
|
+
expect(payload.content_length).toBe(Buffer.byteLength(svg));
|
|
253
|
+
expect(payload.encoding).toBe("base64");
|
|
254
|
+
expect(Buffer.from(payload.data_base64, "base64").toString("utf8")).toBe(svg);
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
await srv.close();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
it("preserves structured 403 detail for binary-endpoint requests", async () => {
|
|
261
|
+
const srv = await startJsonServer((_req, res) => {
|
|
262
|
+
res.statusCode = 403;
|
|
263
|
+
res.setHeader("Content-Type", "application/json");
|
|
264
|
+
res.end(JSON.stringify({
|
|
265
|
+
detail: "This endpoint requires at least the Startup (Pro) tier.",
|
|
266
|
+
}));
|
|
267
|
+
});
|
|
268
|
+
try {
|
|
269
|
+
const client = new BackendClient({
|
|
270
|
+
baseURL: srv.baseURL,
|
|
271
|
+
});
|
|
272
|
+
const message = await getErrorMessage(() => client.request("POST", "/visualization/bi-wheel", {
|
|
273
|
+
params: { format: "png" },
|
|
274
|
+
data: {},
|
|
275
|
+
}));
|
|
276
|
+
expect(message).toContain("Access blocked by plan or policy:");
|
|
277
|
+
expect(message).toContain("Startup (Pro) tier");
|
|
278
|
+
expect(message).toContain("https://openephemeris.com/pay");
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
await srv.close();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { CredentialManager } from "../src/auth/credentials.js";
|
|
5
|
+
// Use a temp directory to avoid polluting the real ~/.openephemeris
|
|
6
|
+
const TEMP_DIR = path.join(os.tmpdir(), `oe-cred-test-${Date.now()}`);
|
|
7
|
+
const TEMP_CREDS_FILE = path.join(TEMP_DIR, "credentials.json");
|
|
8
|
+
// We can't easily override the private constant, so we'll test the public API
|
|
9
|
+
// using a real CredentialManager and clean up after ourselves.
|
|
10
|
+
function makeCredentials(overrides = {}) {
|
|
11
|
+
return {
|
|
12
|
+
access_token: "test-access-token-" + Date.now(),
|
|
13
|
+
refresh_token: "test-refresh-token-" + Date.now(),
|
|
14
|
+
expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
|
|
15
|
+
user_id: "test-user-id",
|
|
16
|
+
user_email: "test@example.com",
|
|
17
|
+
updated_at: new Date().toISOString(),
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
describe("CredentialManager", () => {
|
|
22
|
+
let manager;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
manager = new CredentialManager();
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
// Clean up the default credentials file if it was created by tests
|
|
28
|
+
manager.clear();
|
|
29
|
+
});
|
|
30
|
+
it("returns null when no credentials file exists", () => {
|
|
31
|
+
manager.clear(); // Ensure clean state
|
|
32
|
+
const creds = manager.load();
|
|
33
|
+
// It's either null (no file) or returns existing credentials
|
|
34
|
+
// For a fresh test, it should be null after clear()
|
|
35
|
+
expect(creds).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
it("saves and loads credentials", () => {
|
|
38
|
+
const testCreds = makeCredentials();
|
|
39
|
+
manager.save(testCreds);
|
|
40
|
+
// Create a new manager to test loading from disk
|
|
41
|
+
const freshManager = new CredentialManager();
|
|
42
|
+
const loaded = freshManager.load();
|
|
43
|
+
expect(loaded).not.toBeNull();
|
|
44
|
+
expect(loaded.access_token).toBe(testCreds.access_token);
|
|
45
|
+
expect(loaded.refresh_token).toBe(testCreds.refresh_token);
|
|
46
|
+
expect(loaded.user_email).toBe("test@example.com");
|
|
47
|
+
expect(loaded.user_id).toBe("test-user-id");
|
|
48
|
+
// Cleanup
|
|
49
|
+
freshManager.clear();
|
|
50
|
+
});
|
|
51
|
+
it("detects non-expired credentials", () => {
|
|
52
|
+
const creds = makeCredentials({
|
|
53
|
+
expires_at: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour from now
|
|
54
|
+
});
|
|
55
|
+
expect(manager.isExpired(creds)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
it("detects expired credentials", () => {
|
|
58
|
+
const creds = makeCredentials({
|
|
59
|
+
expires_at: new Date(Date.now() - 1000).toISOString(), // 1 second ago
|
|
60
|
+
});
|
|
61
|
+
expect(manager.isExpired(creds)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it("treats credentials expiring within 2 minutes as expired", () => {
|
|
64
|
+
const creds = makeCredentials({
|
|
65
|
+
expires_at: new Date(Date.now() + 60 * 1000).toISOString(), // 60 seconds from now
|
|
66
|
+
});
|
|
67
|
+
// Within the 120-second buffer, should be considered expired
|
|
68
|
+
expect(manager.isExpired(creds)).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
it("treats null/missing credentials as expired", () => {
|
|
71
|
+
expect(manager.isExpired(null)).toBe(true);
|
|
72
|
+
expect(manager.isExpired(undefined)).toBe(true);
|
|
73
|
+
expect(manager.isExpired({ ...makeCredentials(), expires_at: "" })).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
it("clear removes credentials", () => {
|
|
76
|
+
manager.save(makeCredentials());
|
|
77
|
+
expect(manager.load()).not.toBeNull();
|
|
78
|
+
manager.clear();
|
|
79
|
+
expect(manager.load()).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
it("clear is idempotent (doesn't throw on missing file)", () => {
|
|
82
|
+
manager.clear();
|
|
83
|
+
expect(() => manager.clear()).not.toThrow();
|
|
84
|
+
});
|
|
85
|
+
it("getStatus returns authenticated: false when no credentials", () => {
|
|
86
|
+
manager.clear();
|
|
87
|
+
const status = manager.getStatus();
|
|
88
|
+
expect(status.authenticated).toBe(false);
|
|
89
|
+
expect(status.email).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
it("getStatus returns authenticated: true for valid credentials", () => {
|
|
92
|
+
const creds = makeCredentials({
|
|
93
|
+
expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
|
|
94
|
+
user_email: "status@example.com",
|
|
95
|
+
});
|
|
96
|
+
manager.save(creds);
|
|
97
|
+
const status = manager.getStatus();
|
|
98
|
+
expect(status.authenticated).toBe(true);
|
|
99
|
+
expect(status.email).toBe("status@example.com");
|
|
100
|
+
expect(status.expiresAt).toBe(creds.expires_at);
|
|
101
|
+
manager.clear();
|
|
102
|
+
});
|
|
103
|
+
it("getStatus returns authenticated: false for expired credentials", () => {
|
|
104
|
+
const creds = makeCredentials({
|
|
105
|
+
expires_at: new Date(Date.now() - 1000).toISOString(),
|
|
106
|
+
});
|
|
107
|
+
manager.save(creds);
|
|
108
|
+
const status = manager.getStatus();
|
|
109
|
+
expect(status.authenticated).toBe(false);
|
|
110
|
+
manager.clear();
|
|
111
|
+
});
|
|
112
|
+
it("saves with updated_at timestamp", () => {
|
|
113
|
+
const before = Date.now();
|
|
114
|
+
manager.save(makeCredentials());
|
|
115
|
+
const loaded = manager.load();
|
|
116
|
+
expect(loaded).not.toBeNull();
|
|
117
|
+
const updatedAt = new Date(loaded.updated_at).getTime();
|
|
118
|
+
expect(updatedAt).toBeGreaterThanOrEqual(before);
|
|
119
|
+
expect(updatedAt).toBeLessThanOrEqual(Date.now());
|
|
120
|
+
manager.clear();
|
|
121
|
+
});
|
|
122
|
+
it("getValidToken returns null when no credentials", async () => {
|
|
123
|
+
manager.clear();
|
|
124
|
+
const token = await manager.getValidToken();
|
|
125
|
+
expect(token).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
it("getValidToken returns token when not expired", async () => {
|
|
128
|
+
const creds = makeCredentials({
|
|
129
|
+
access_token: "valid-token-123",
|
|
130
|
+
expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
|
|
131
|
+
});
|
|
132
|
+
manager.save(creds);
|
|
133
|
+
const token = await manager.getValidToken();
|
|
134
|
+
expect(token).toBe("valid-token-123");
|
|
135
|
+
manager.clear();
|
|
136
|
+
});
|
|
137
|
+
it("credentialsPath is a string in the home directory", () => {
|
|
138
|
+
const credPath = CredentialManager.credentialsPath;
|
|
139
|
+
expect(typeof credPath).toBe("string");
|
|
140
|
+
expect(credPath).toContain(".openephemeris");
|
|
141
|
+
expect(credPath).toContain("credentials.json");
|
|
142
|
+
});
|
|
143
|
+
});
|