@openephemeris/mcp-server 3.19.0 → 3.20.0

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/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [3.20.0] — 2026-07-09
11
+
12
+ Second wave of the transport/iframe review: legacy transport retirement, resumability, and remaining hardening/quality items.
13
+
14
+ ### Removed
15
+ - **Legacy SSE transport retired.** `GET /sse` + `POST /message` (pre-2025 spec, SDK-deprecated, no cross-machine replay, zero live sessions) now return `410 Gone` pointing at `/mcp`. `scripts/test-sse-client.ts` and the `test:sse` script removed; docs updated.
16
+
17
+ ### Added
18
+ - **`Last-Event-ID` resumability.** Bounded in-memory `EventStore` (200 events/stream, 2h TTL) on the Streamable HTTP transport — a reconnecting SSE stream replays missed events instead of losing them. Per-process is safe because sessions are machine-pinned via fly-replay.
19
+ - **Server-computed natal aspects.** `explore_natal_chart` was discarding the `aspects` array the natal endpoint already returns (with true ephemeris-derived `is_applying`) and re-deriving aspects client-side with an approximate heuristic — `computeAspects` deleted, server aspects mapped through. Bi-wheel cross-aspects stay client-side (two charts, no single endpoint) but the applying/separating heuristic now uses real longitude speeds instead of defaulted guesses.
20
+ - **CSP + keyboard accessibility** on the four iframe apps: inline-only `Content-Security-Policy` meta on each shell; interactive SVG elements (planets, houses, aspects, centers/gates/channels) get `tabindex`/`role`/`aria-label`, Enter/Space activation, and visible focus indicators.
21
+ - **Real-handler test coverage.** `createSseApp()` is now exported and `test/server-sse-real-app.test.ts` drives the actual Express app: 401/WWW-Authenticate contract, expired-JWT refresh signal, session issuance with machine prefix, cross-machine `fly-replay` 307 + loop-guard 404, resume-auth requirement, Host allowlist, and the 410 legacy responses.
22
+ - **`check:plugin` release gate** (`scripts/plugin-audit.ts`): plugin.json version must match the package (it had rotted at 3.1.0) and `openephemeris-plugin.zip` must match the plugin tree by content; `regen:plugin` rebuilds both. Wired into `verify:release`.
23
+ - **CI:** `validate:visual` (Playwright + axe over the app harness) added to the validate workflow; the live canary now sends a **Telegram alert on failure** (needs `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID` secrets; no-ops without them).
24
+
25
+ ### Changed
26
+ - Resume-path auth now runs **before** the session lookup so an unauthenticated probe cannot learn whether a session id exists.
27
+
28
+ ---
29
+
10
30
  ## [3.19.0] — 2026-07-09
11
31
 
12
32
  Transport hardening + E2E release verification, from a full review of the streaming HTTP transport and iframe app capability.
package/README.md CHANGED
@@ -187,7 +187,7 @@ The server is hosted at `https://mcp.openephemeris.com/mcp` with full Streamable
187
187
 
188
188
  - **Claude Web**: Add `https://mcp.openephemeris.com/mcp` as a custom connector URL — leave OAuth Client ID and Secret **blank**. The server uses OAuth 2.1 + PKCE (Dynamic Client Registration), so Claude handles authentication via a browser popup automatically.
189
189
  - **Via Smithery**: Use the [Smithery listing](https://smithery.ai/servers/open-ephemeris/openephemeris) for managed connections with any client
190
- - **Legacy SSE**: `https://mcp.openephemeris.com/sse` remains available for SSE-only clients
190
+ - **Legacy SSE**: retired in 3.20.0 use Streamable HTTP at `/mcp`
191
191
 
192
192
  ### Auth and upgrade behavior in MCP clients
193
193
 
@@ -0,0 +1,11 @@
1
+ import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { EventStore, EventId, StreamId } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ export declare class InMemoryEventStore implements EventStore {
4
+ private streams;
5
+ storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId>;
6
+ replayEventsAfter(lastEventId: EventId, { send }: {
7
+ send: (eventId: EventId, message: JSONRPCMessage) => Promise<void>;
8
+ }): Promise<StreamId>;
9
+ /** Drop streams idle past the TTL so abandoned sessions don't leak memory. */
10
+ private reapStale;
11
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * event-store.ts — bounded in-memory EventStore for Streamable HTTP
3
+ * resumability (Last-Event-ID).
4
+ *
5
+ * Without an event store the transport cannot replay server→client messages
6
+ * missed across an SSE reconnect — a dropped connection silently loses any
7
+ * buffered events. This store keeps a small per-session ring of recent events
8
+ * so a client reconnecting with Last-Event-ID resumes cleanly.
9
+ *
10
+ * Deliberately in-memory and per-process: sessions are already machine-pinned
11
+ * (fly-replay routes a session's requests to its owning machine), so the
12
+ * events for a session always live on the machine handling its reconnect.
13
+ */
14
+ import { randomUUID } from "node:crypto";
15
+ const MAX_EVENTS_PER_STREAM = 200;
16
+ const STREAM_TTL_MS = 2 * 60 * 60 * 1000; // matches the session idle TTL
17
+ export class InMemoryEventStore {
18
+ streams = new Map();
19
+ async storeEvent(streamId, message) {
20
+ // Event IDs embed the stream ID so replay can locate the stream without a
21
+ // secondary index: "<streamId>__<uuid>".
22
+ const eventId = `${streamId}__${randomUUID()}`;
23
+ let stream = this.streams.get(streamId);
24
+ if (!stream) {
25
+ stream = { events: [], lastTouched: Date.now() };
26
+ this.streams.set(streamId, stream);
27
+ }
28
+ stream.events.push({ eventId, message });
29
+ stream.lastTouched = Date.now();
30
+ if (stream.events.length > MAX_EVENTS_PER_STREAM) {
31
+ stream.events.splice(0, stream.events.length - MAX_EVENTS_PER_STREAM);
32
+ }
33
+ this.reapStale();
34
+ return eventId;
35
+ }
36
+ async replayEventsAfter(lastEventId, { send }) {
37
+ const sep = lastEventId.lastIndexOf("__");
38
+ const streamId = sep > 0 ? lastEventId.slice(0, sep) : "";
39
+ const stream = this.streams.get(streamId);
40
+ if (!stream)
41
+ return streamId;
42
+ const idx = stream.events.findIndex((e) => e.eventId === lastEventId);
43
+ // If the event aged out of the ring, replay nothing rather than
44
+ // duplicating from an unknown point — the client re-requests as needed.
45
+ if (idx === -1)
46
+ return streamId;
47
+ for (const event of stream.events.slice(idx + 1)) {
48
+ await send(event.eventId, event.message);
49
+ }
50
+ stream.lastTouched = Date.now();
51
+ return streamId;
52
+ }
53
+ /** Drop streams idle past the TTL so abandoned sessions don't leak memory. */
54
+ reapStale() {
55
+ const cutoff = Date.now() - STREAM_TTL_MS;
56
+ for (const [id, stream] of this.streams) {
57
+ if (stream.lastTouched < cutoff)
58
+ this.streams.delete(id);
59
+ }
60
+ }
61
+ }
@@ -1 +1,7 @@
1
- export {};
1
+ import express from "express";
2
+ /**
3
+ * Build the full Express app (auth gate, OAuth routes, /mcp Streamable HTTP,
4
+ * health, resources). Exported so tests can drive the REAL handlers with
5
+ * supertest instead of a re-implemented stub. Does not listen.
6
+ */
7
+ export declare function createSseApp(): Promise<express.Application>;
@@ -14,13 +14,13 @@
14
14
  */
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
- import { fileURLToPath } from "node:url";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { randomUUID } from "node:crypto";
19
19
  import express from "express";
20
20
  import axios from "axios";
21
21
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
22
- import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
23
22
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
23
+ import { InMemoryEventStore } from "./event-store.js";
24
24
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
25
25
  import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools } from "./tools/index.js";
26
26
  import { BackendClient, runWithClient } from "./backend/client.js";
@@ -418,12 +418,17 @@ function startSseKeepalive(res) {
418
418
  }, 25_000);
419
419
  res.on("close", () => clearInterval(ping));
420
420
  }
421
- async function main() {
421
+ /**
422
+ * Build the full Express app (auth gate, OAuth routes, /mcp Streamable HTTP,
423
+ * health, resources). Exported so tests can drive the REAL handlers with
424
+ * supertest instead of a re-implemented stub. Does not listen.
425
+ */
426
+ export async function createSseApp() {
422
427
  await initTools();
423
428
  const app = express();
424
- // NOTE: Do NOT use express.json() globally SSEServerTransport.handlePostMessage
425
- // reads the raw request stream itself. Pre-parsing with express.json() consumes it,
426
- // causing "stream is not readable" errors.
429
+ // NOTE: express.json() is applied per-route (jsonParser on /mcp POST), not
430
+ // globally the OAuth token route needs urlencoded, and transports that
431
+ // read the raw request stream break if the body is pre-consumed.
427
432
  // CORS + Origin allowlist
428
433
  // Per Anthropic remote-MCP review criteria, browser-originated requests must
429
434
  // be validated against an allowlist. Native (no-Origin) clients like Claude
@@ -455,7 +460,7 @@ async function main() {
455
460
  .filter(Boolean)
456
461
  .map((h) => new RegExp(`^${h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(:\\d+)?$`, "i")),
457
462
  ];
458
- const MCP_ROUTES = new Set(["/mcp", "/sse", "/message"]);
463
+ const MCP_ROUTES = new Set(["/mcp"]);
459
464
  app.use((req, res, next) => {
460
465
  if (MCP_ROUTES.has(req.path)) {
461
466
  const host = req.headers.host;
@@ -491,10 +496,6 @@ async function main() {
491
496
  }
492
497
  next();
493
498
  });
494
- // Track active transports by session ID
495
- const transports = new Map();
496
- // Per-session BackendClient — each connection authenticates independently.
497
- const sessionClients = new Map();
498
499
  // ---------------------------------------------------------------------------
499
500
  // OAuth 2.1 routes — must be mounted BEFORE JSON body parser
500
501
  // ---------------------------------------------------------------------------
@@ -531,14 +532,12 @@ async function main() {
531
532
  sessionRateLimiter(req, res, next);
532
533
  };
533
534
  app.use("/mcp", throttleSessionCreation);
534
- app.use("/sse", sessionRateLimiter);
535
535
  // Health check
536
536
  app.get("/health", (_req, res) => {
537
537
  res.json({
538
538
  ok: true,
539
539
  version,
540
- transports: ["sse", "streamable-http"],
541
- sse_sessions: transports.size,
540
+ transports: ["streamable-http"],
542
541
  http_sessions: httpSessions.size,
543
542
  });
544
543
  });
@@ -591,6 +590,27 @@ async function main() {
591
590
  const sessionId = req.headers["mcp-session-id"];
592
591
  // Resume existing session
593
592
  if (sessionId) {
593
+ // A session id alone is not a credential: require a key/JWT BEFORE the
594
+ // session lookup so an unauthenticated probe can't learn whether a
595
+ // session id exists, and reject an expired JWT with the OAuth-refresh
596
+ // signal (same semantics as the init gate below).
597
+ const resumedAuth = extractAuth(req);
598
+ if (!resumedAuth.apiKey && !resumedAuth.jwt) {
599
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
600
+ res.status(401).json({
601
+ error: "auth_required",
602
+ message: "Authentication required on every request. Pass an API key via X-API-Key header, or a Bearer token via Authorization header.",
603
+ });
604
+ return;
605
+ }
606
+ if (resumedAuth.jwt && isJwtExpired(resumedAuth.jwt)) {
607
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
608
+ res.status(401).json({
609
+ error: "invalid_token",
610
+ message: "Your session token has expired. Re-authorize to continue.",
611
+ });
612
+ return;
613
+ }
594
614
  const session = httpSessions.get(sessionId);
595
615
  if (!session) {
596
616
  // Cross-machine session recovery (Fly): min_machines_running >= 2 but
@@ -612,26 +632,6 @@ async function main() {
612
632
  });
613
633
  return;
614
634
  }
615
- // A session id alone is not a credential: require the caller to still
616
- // present a key/JWT on every resume, and reject an expired JWT with the
617
- // OAuth-refresh signal (same semantics as the init gate below).
618
- const resumedAuth = extractAuth(req);
619
- if (!resumedAuth.apiKey && !resumedAuth.jwt) {
620
- res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
621
- res.status(401).json({
622
- error: "auth_required",
623
- message: "Authentication required on every request. Pass an API key via X-API-Key header, or a Bearer token via Authorization header.",
624
- });
625
- return;
626
- }
627
- if (resumedAuth.jwt && isJwtExpired(resumedAuth.jwt)) {
628
- res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
629
- res.status(401).json({
630
- error: "invalid_token",
631
- message: "Your session token has expired. Re-authorize to continue.",
632
- });
633
- return;
634
- }
635
635
  // Refresh the session's Bearer JWT if the host rotated it. claude.ai
636
636
  // refreshes tokens via /oauth/token roughly hourly (self-signed Supabase
637
637
  // JWTs expire in 1h); the frozen JWT captured at init would otherwise 401
@@ -706,6 +706,10 @@ async function main() {
706
706
  // can be replayed to the owner (see the resume branch above). No-op off
707
707
  // Fly (FLY_MACHINE_ID unset) — the raw UUID is used unchanged.
708
708
  sessionIdGenerator: () => encodeSessionId(randomUUID()),
709
+ // Last-Event-ID resumability: a reconnecting SSE stream replays the
710
+ // events it missed instead of silently losing them. Per-process is fine
711
+ // — the session is machine-pinned via fly-replay.
712
+ eventStore: new InMemoryEventStore(),
709
713
  onsessioninitialized: (id) => {
710
714
  httpSessions.set(id, { server, transport, client, lastSeen: Date.now() });
711
715
  console.error(`[HTTP] Session initialized: ${id}`);
@@ -796,101 +800,23 @@ async function main() {
796
800
  }
797
801
  });
798
802
  // ---------------------------------------------------------------------------
799
- // Legacy SSE transport — MCP spec pre-2025 (kept for backward compatibility)
800
- // Endpoint: GET https://mcp.openephemeris.com/sse
801
- // New integrations should prefer /mcp (Streamable HTTP above).
803
+ // Legacy SSE transport — RETIRED in 3.20.0.
804
+ // The pre-2025 HTTP+SSE transport (/sse + /message) was SDK-deprecated, had
805
+ // no cross-machine replay (broken with min_machines_running >= 2), and live
806
+ // metrics showed zero sessions. 410 Gone points stragglers at /mcp.
802
807
  // ---------------------------------------------------------------------------
803
- // SSE endpoint clients GET /sse to establish a stream
804
- app.get("/sse", async (req, res) => {
805
- // Extract auth from query param or header (supports API keys + OAuth JWTs)
806
- const { apiKey, jwt } = extractAuth(req);
807
- if (!apiKey && !jwt) {
808
- res.status(401).json({
809
- error: "auth_required",
810
- message: "Authentication required. Pass an API key via X-API-Key header or query param, or a Bearer JWT via Authorization header. Get a free key at https://openephemeris.com/dashboard?tab=apikeys",
811
- });
812
- return;
813
- }
814
- // Validate API keys against the Go backend; JWTs validated downstream.
815
- if (apiKey) {
816
- const keyStatus = await validateApiKey(apiKey);
817
- if (keyStatus === "invalid") {
818
- res.status(403).json({
819
- error: "invalid_api_key",
820
- message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
821
- });
822
- return;
823
- }
824
- // Fail closed for browser clients when validation is unavailable (APP-6).
825
- if (keyStatus === "unknown" && isBrowserOrigin(req)) {
826
- res.set("Retry-After", "5");
827
- res.status(503).json({
828
- error: "validation_unavailable",
829
- message: "Could not verify your API key right now. Please retry shortly.",
830
- });
831
- return;
832
- }
833
- }
834
- const transport = new SSEServerTransport("/message", res);
835
- const sessionId = transport.sessionId;
836
- transports.set(sessionId, transport);
837
- // Create a per-session BackendClient so concurrent SSE connections
838
- // don't overwrite each other's API key on the shared singleton.
839
- const sessionClient = new BackendClient({
840
- baseURL: BACKEND_URL,
841
- apiKey,
842
- jwt,
808
+ const legacySseGone = (_req, res) => {
809
+ res.status(410).json({
810
+ error: "transport_retired",
811
+ message: "The legacy SSE transport has been retired. Connect via Streamable HTTP at https://mcp.openephemeris.com/mcp instead.",
843
812
  });
844
- sessionClients.set(sessionId, sessionClient);
845
- const server = createMcpServer();
846
- // Guard: prevent double-close from crashing the process
847
- let closed = false;
848
- const cleanup = () => {
849
- if (closed)
850
- return;
851
- closed = true;
852
- transports.delete(sessionId);
853
- sessionClients.delete(sessionId);
854
- try {
855
- server.close().catch(() => { });
856
- }
857
- catch { /* ignore */ }
858
- };
859
- transport.onclose = cleanup;
860
- // Handle abrupt client disconnects (browser tab closed, etc.)
861
- res.on("close", cleanup);
862
- try {
863
- // server.connect() calls transport.start() internally in SDK v1.27+
864
- await server.connect(transport);
865
- // Stream is established — keep it alive through Fly's proxy.
866
- startSseKeepalive(res);
867
- }
868
- catch (err) {
869
- console.error(`SSE session ${sessionId} failed to start:`, err);
870
- cleanup();
871
- }
872
- });
873
- // Message endpoint — clients POST /message?sessionId=xxx
874
- app.post("/message", async (req, res) => {
875
- const sessionId = req.query.sessionId;
876
- const transport = transports.get(sessionId);
877
- const sessionClient = sessionClients.get(sessionId);
878
- if (!transport) {
879
- res.status(400).json({
880
- error: "invalid_session",
881
- message: "Unknown or expired session. Re-connect to /sse first.",
882
- });
883
- return;
884
- }
885
- // Run handlePostMessage inside the session's client context so all tool
886
- // handlers reach getActiveClient() and get this user's BackendClient.
887
- if (sessionClient) {
888
- await runWithClient(sessionClient, () => transport.handlePostMessage(req, res));
889
- }
890
- else {
891
- await transport.handlePostMessage(req, res);
892
- }
893
- });
813
+ };
814
+ app.get("/sse", legacySseGone);
815
+ app.post("/message", legacySseGone);
816
+ return app;
817
+ }
818
+ async function main() {
819
+ const app = await createSseApp();
894
820
  // Reap abandoned sessions (no DELETE, no transport close) every 10 minutes.
895
821
  setInterval(reapIdleSessions, 10 * 60 * 1000).unref();
896
822
  const httpServer = app.listen(PORT, "0.0.0.0", () => {
@@ -919,7 +845,13 @@ process.on("uncaughtException", (err) => {
919
845
  process.on("unhandledRejection", (reason) => {
920
846
  console.error("Unhandled promise rejection:", reason);
921
847
  });
922
- main().catch((error) => {
923
- console.error("Fatal error:", error);
924
- process.exit(1);
925
- });
848
+ // Only listen when run as the entry point — importing createSseApp from
849
+ // tests must not bind a port or install the reaper interval.
850
+ const isDirectRun = process.argv[1] !== undefined &&
851
+ import.meta.url.toLowerCase() === pathToFileURL(process.argv[1]).href.toLowerCase();
852
+ if (isDirectRun) {
853
+ main().catch((error) => {
854
+ console.error("Fatal error:", error);
855
+ process.exit(1);
856
+ });
857
+ }
@@ -248,15 +248,17 @@ export function computeCrossAspects(planets1, planets2) {
248
248
  const results = [];
249
249
  for (const p1 of planets1) {
250
250
  for (const p2 of planets2) {
251
- let diff = Math.abs(p1.longitude - p2.longitude) % 360;
252
- if (diff > 180)
253
- diff = 360 - diff;
251
+ // Signed separation of p2 ahead of p1 (0–360), folded to shortest arc.
252
+ const delta = (((p2.longitude - p1.longitude) % 360) + 360) % 360;
253
+ const diff = delta > 180 ? 360 - delta : delta;
254
+ // Rate of change of the shortest-arc separation, from real daily motion.
255
+ const rel = (p2.speed ?? 0) - (p1.speed ?? 0);
256
+ const dDiff = delta > 180 ? -rel : rel;
254
257
  for (const asp of CROSS_ASPECT_DEFS) {
255
258
  const orb = Math.abs(diff - asp.angle);
256
259
  if (orb <= asp.orb) {
257
- const applying = ((p1.speed ?? 1) - (p2.speed ?? 0)) < 0
258
- ? diff < asp.angle
259
- : diff > asp.angle;
260
+ // Applying = the separation is moving toward the exact aspect angle.
261
+ const applying = (diff - asp.angle) * dDiff < 0;
260
262
  results.push({
261
263
  planet1: p1.name,
262
264
  wheel1: "inner",
@@ -189,7 +189,7 @@ registerTool({
189
189
  const chartData = await client.post("/ephemeris/natal-chart", natalBody);
190
190
  // Build human-readable summary for the LLM context
191
191
  const summary = buildChartSummary(chartData, String(args.location ?? `${lat}, ${lon}`));
192
- // Build the UI payload (planets normalised to array, aspects computed)
192
+ // Build the UI payload (planets normalised to array, server aspects mapped)
193
193
  const modelPayload = buildModelPayload(chartData, {
194
194
  datetime,
195
195
  timezone: args.timezone ?? null,
@@ -453,52 +453,39 @@ const ALL_KNOWN_BODIES = new Set([...CLASSICAL_PLANETS, ...EXTENDED_BODIES]);
453
453
  * Tokens saved vs. full uiPayload: ~85–95% reduction (no 100KB SVG, no asteroid flood).
454
454
  */
455
455
  /**
456
- * Compute major aspects between a set of planets.
457
- * Returns a structured AspectData[] array the natal chart API does not
458
- * include aspects in its JSON response; we derive them from longitude data.
456
+ * Map the server-computed aspects array from /ephemeris/natal-chart (and the
457
+ * solar-return / progressed responses) to the AspectData shape the chart-wheel
458
+ * UI consumes: { planet1, planet2, type, angle, orb, applying }.
459
+ *
460
+ * The Go API computes aspects by default (options.include_aspects = true),
461
+ * including a proper ephemeris-based is_applying flag — so no client-side
462
+ * longitude heuristic is needed. Aspects are optionally filtered to the set of
463
+ * bodies actually displayed so lines never reference a hidden planet.
459
464
  */
460
- function computeAspects(planets) {
461
- const ASPECTS = [
462
- { type: "conjunction", angle: 0, orb: 8 },
463
- { type: "opposition", angle: 180, orb: 8 },
464
- { type: "trine", angle: 120, orb: 8 },
465
- { type: "square", angle: 90, orb: 7 },
466
- { type: "sextile", angle: 60, orb: 6 },
467
- { type: "quincunx", angle: 150, orb: 3 },
468
- { type: "semisquare", angle: 45, orb: 2 },
469
- ];
465
+ function mapServerAspects(rawAspects, allowedNames) {
466
+ if (!Array.isArray(rawAspects))
467
+ return [];
470
468
  const results = [];
471
- for (let i = 0; i < planets.length; i++) {
472
- for (let j = i + 1; j < planets.length; j++) {
473
- const p1 = planets[i];
474
- const p2 = planets[j];
475
- // Angular separation: shortest arc (0–180)
476
- let diff = Math.abs(p1.longitude - p2.longitude) % 360;
477
- if (diff > 180)
478
- diff = 360 - diff;
479
- for (const asp of ASPECTS) {
480
- const orb = Math.abs(diff - asp.angle);
481
- // Tighten orbs when one or both bodies are extended (asteroids, etc.)
482
- const p1Extended = EXTENDED_BODIES.has(p1.name.toLowerCase());
483
- const p2Extended = EXTENDED_BODIES.has(p2.name.toLowerCase());
484
- const effectiveOrb = (p1Extended || p2Extended)
485
- ? Math.min(asp.orb * 0.5, 3) // half standard, capped at 3°
486
- : asp.orb;
487
- if (orb <= effectiveOrb) {
488
- // Applying: combined longitudinal speed closing the orb
489
- const applying = ((p1.speed ?? 1) - (p2.speed ?? 0)) < 0 ? diff < asp.angle : diff > asp.angle;
490
- results.push({
491
- planet1: p1.name,
492
- planet2: p2.name,
493
- type: asp.type,
494
- angle: asp.angle,
495
- orb: Math.round(orb * 100) / 100,
496
- applying,
497
- });
498
- break; // Each pair only matches one aspect type
499
- }
500
- }
501
- }
469
+ for (const a of rawAspects) {
470
+ if (!a || typeof a !== "object")
471
+ continue;
472
+ const p1 = canonicalizeBodyName(String(a.planet1 ?? ""));
473
+ const p2 = canonicalizeBodyName(String(a.planet2 ?? ""));
474
+ if (!p1 || !p2)
475
+ continue;
476
+ if (allowedNames && (!allowedNames.has(p1) || !allowedNames.has(p2)))
477
+ continue;
478
+ results.push({
479
+ planet1: p1,
480
+ planet2: p2,
481
+ type: String(a.aspect_type ?? a.type ?? "aspect").toLowerCase(),
482
+ angle: Number(a.exact_angle ?? a.angle ?? 0),
483
+ orb: Math.round(Number(a.orb ?? 0) * 100) / 100,
484
+ // is_applying may be null when the server cannot determine it
485
+ applying: typeof a.is_applying === "boolean" ? a.is_applying
486
+ : typeof a.applying === "boolean" ? a.applying
487
+ : undefined,
488
+ });
502
489
  }
503
490
  return results;
504
491
  }
@@ -536,8 +523,8 @@ function buildModelPayload(chartData, birthParams, houseSystem, svgBase, bodyFil
536
523
  // Already normalised (e.g. house system recalculate path)
537
524
  planetsArray = chartData.planets;
538
525
  }
539
- // ── 2. Compute aspects from longitude data (API doesn't return them) ──────
540
- const aspects = computeAspects(planetsArray);
526
+ // ── 2. Map server-computed aspects (filtered to displayed bodies) ─────────
527
+ const aspects = mapServerAspects(chartData.aspects, new Set(planetsArray.map((p) => p.name)));
541
528
  // ── 3. Extract angles — API nests ascendant/MC under an `angles` key ──────
542
529
  const rawAngles = chartData.angles;
543
530
  const rawHouseObj = chartData.houses;
@@ -626,10 +613,8 @@ function buildChartSummary(data, location) {
626
613
  elementCount[el]++;
627
614
  }
628
615
  const domElement = Object.entries(elementCount).sort((a, b) => b[1] - a[1])[0];
629
- // Strongest aspect by tightest orb (use computed aspects from planetsArray)
630
- const aspects = computeAspects(planetsArray
631
- .filter((p) => p.longitude != null)
632
- .map((p) => ({ name: p.name, longitude: p.longitude, speed: p.longitude_speed })));
616
+ // Strongest aspect by tightest orb (server-computed aspects)
617
+ const aspects = mapServerAspects(data.aspects);
633
618
  const strongest = aspects.sort((a, b) => a.orb - b.orb)[0];
634
619
  const strongestNote = strongest
635
620
  ? `Closest aspect: ${capitalize(strongest.planet1)} ${strongest.type} ${capitalize(strongest.planet2)} (${strongest.orb.toFixed(1)}° orb)`