From a8f6a57ccc8675a815abf41ca08fdfeb13ab7abe Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Sat, 11 Jul 2026 23:24:54 +0800 Subject: [PATCH] harden calculation contracts and local API boundaries --- docs/research/pre_work_error_ledger.md | 14 + jyotish-app/ai-chat.js | 1 - jyotish-app/api-bridge.js | 18 ++ jyotish-app/auth.js | 3 +- pyproject.toml | 1 + requirements.txt | 1 + scripts/domain_calculation_service.py | 260 ++++++++++++++++ scripts/jyotish_api_server.py | 377 +++++++++++++++-------- scripts/jyotish_engine.py | 24 +- scripts/report_builder.py | 23 +- scripts/three_engine_parity_runner.py | 168 ++++++++++ scripts/timezone_utils.py | 18 +- tests/conftest.py | 16 + tests/test_api_server_security.py | 9 +- tests/test_calculation_p0_regressions.py | 117 +++++++ tests/test_runtime_security_p0.py | 156 ++++++++++ tests/test_three_engine_parity_runner.py | 26 ++ 17 files changed, 1075 insertions(+), 157 deletions(-) create mode 100644 scripts/domain_calculation_service.py create mode 100644 scripts/three_engine_parity_runner.py create mode 100644 tests/test_calculation_p0_regressions.py create mode 100644 tests/test_runtime_security_p0.py create mode 100644 tests/test_three_engine_parity_runner.py diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index ad2d5dde..b7dd94ae 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -64,6 +64,20 @@ For large architecture or release work, also read: | ERR-031 | Premium skill zip can ship without user install prompts or replay schemas, leaving users and future oracle imports without a contract. | mitigated 2026-07-09 | `skill_release_package.py` must inject `INSTALL.md` and `USER_PROMPTS.md`; replay contracts must live in `references/real_case_calibration/` and `references/oracle/`. | | ERR-032 | Full smoke files can time out while focused slices pass; `test_full_reading_reports_ayanamsa_metadata_and_ai_prompt_pack` currently exposes `external_oracle_gap_summary=null`. | observed 2026-07-10 | Do not claim full `tests/test_cli_smoke.py` or full `tests/test_vedastro_external_technique_evidence.py` passed unless run to completion; use focused slices for related changes and track the prompt-pack gap separately. | | ERR-033 | Premium skill zip validation can accidentally depend on a parent Git repository, so a cloud-drive user may fail in a clean unzip directory. | mitigated 2026-07-10 | Release acceptance must include `tests/test_skill_release_clean_trial.py`; scripts such as `public_release_privacy_scan.py` must support non-Git unpacked zip directories. | +| ERR-034 | A single `historical_event_backtest.build_report()` strict replay can exceed 120 seconds before returning a case result. | observed 2026-07-11 | Use `scripts/public_real_case_benchmark.py` for bounded batch evidence replay; keep strict workflow as a separately timed probe and report timeout as blocked. | +| ERR-035 | `benchmarks/jyotish/scripts/run_pyjhora_compare.py --help` executes the benchmark and crashes when canonical fixtures are absent. | observed 2026-07-11 | Do not claim PyJHora parity from readiness. Generate canonical fixtures or harden the runner before the next same-chart batch. | +| ERR-036 | `public_real_case_benchmark.py --rule-version compare` originally replayed both rule versions and exceeded the 120-second command budget. | mitigated 2026-07-11 | Compare mode must read precomputed `--comparison-v1` and `--comparison-v2` reports; never duplicate engine replay inside comparison. | +| ERR-037 | `scripts/muntha.py` failed at import because `List` was used in an annotation but not imported. | resolved 2026-07-11 | Keep `tests/test_muntha_module.py`; a technique file does not count as available unless it imports and runs independently. | +| ERR-038 | Real-case scoring counted the same planet twice when MD and AD had the same lord, inflating strong-hit scores and duplicating signals. | mitigated 2026-07-11 | V2.1 must deduplicate active lords before `_planet_score`; keep the same-MD/AD regression test and preserve legacy V2 reports for audit only. | +| ERR-039 | `exact_label_rate` looked like classification accuracy even though the benchmark already knew the event domain and assigned the expected label at the strong threshold. | mitigated 2026-07-11 | Use `known_event_activation_rate` and `strong_activation_rate`; keep old names deprecated and never present them as predictive accuracy. | +| ERR-040 | `.gitignore` excluded only parts of `scratch/`, leaving local helper files and `.serena/` visible to `git add .`. | resolved 2026-07-11 | Ignore `/scratch/` and `/.serena/` at repo root; keep a regression test for both private workspace directories. | +| ERR-041 | Positive-event replay scores were interpreted as timing evidence even though nearby non-target dates could receive equal or higher scores. | mitigated 2026-07-11 | Keep the negative-control date-ranking pilot and `timing_precision_gate`; block exact-day/month claims while Top-3 ranking remains below the gate. | +| ERR-042 | REST duplicated natal chart, Vimshottari and Sade Sati calculations, so True Node was ignored, the first Dasha balance drifted, and Saturn transit was fabricated. | resolved 2026-07-11 | Keep `tests/test_calculation_p0_regressions.py`; domain/CLI/REST must share `domain_calculation_service.py`, effective parameters and `result_hash`. | +| ERR-043 | Localhost POST requests trusted CORS response headers as an execution guard; report Chromium could load external/local resources; async job IDs were predictable and persisted without capability authentication or TTL. | mitigated 2026-07-11 | Keep `tests/test_runtime_security_p0.py`; enforce Origin/Host/JSON, sandbox report resources, use random capability tokens, `0600` atomic records, TTL deletion and a bounded worker queue. Run an isolated Chromium network PoC before declaring the renderer fully hardened. | +| ERR-044 | Focused selections that include legacy full chart API tests can still exceed the 120-second desktop command budget even after pure calculation tests pass. | observed 2026-07-11 | Keep P0 calculation/security tests pure and fast; profile the legacy chart fixture separately before using the full API file as a blocking CI gate. | +| ERR-045 | Three-engine readiness was mistaken for completed same-chart parity. Public replay on 2026-07-11 captured PyJHora and jyotishganit raw, but VedAstro returned `official_snapshot_budget_exhausted` with no raw response. | active external blocker | Keep `three_engine_parity_runner.py`; status remains `blocked`/`partial` until all required raw artifacts are normalized into comparison rows. | +| ERR-046 | Report-renderer SSRF/file PoC could not run because the Playwright Chromium binary was absent and installation exceeded the desktop outer timeout. | blocked environment | Keep route/JS-denial tests; rerun isolated HTTP/file PoC only after a verified Chromium installation, then update this ledger with the measured request count. | +| ERR-047 | Initial `slow` marker partition for `test_api_server_security.py` still exceeded the 120-second desktop budget; heavy paths extend beyond VedAstro/high-rigor prefix groups. | active profiling blocker | Profile test node IDs in bounded subprocess batches, mark only measured heavy tests, and keep fast-security acceptance separate from long CI integration coverage. | ## Fragment Sweep Command Set diff --git a/jyotish-app/ai-chat.js b/jyotish-app/ai-chat.js index a15533e4..11aaa57a 100644 --- a/jyotish-app/ai-chat.js +++ b/jyotish-app/ai-chat.js @@ -444,7 +444,6 @@ function buildAISetupGuidance() { function getApiBase() { if (window.JYOTISH_API_BASE) return window.JYOTISH_API_BASE; if (import.meta.env?.VITE_JYOTISH_API_BASE) return import.meta.env.VITE_JYOTISH_API_BASE; - if (window.Capacitor?.isNativePlatform?.()) return localStorage.getItem('jyotish_api_base') || ''; return ''; // 同域部署 } diff --git a/jyotish-app/api-bridge.js b/jyotish-app/api-bridge.js index 66286f2c..e48e5e39 100644 --- a/jyotish-app/api-bridge.js +++ b/jyotish-app/api-bridge.js @@ -47,6 +47,7 @@ async function postJson(path, payload, { requireModernChart = false } = {}) { continue; } activeApiBase = base; + if (data?.mode === 'async_submitted') return pollAsyncJob(data, { base }); return data; } catch (error) { lastAttempt = `${base}${path}`; @@ -60,6 +61,22 @@ async function postJson(path, payload, { requireModernChart = false } = {}) { throw lastError || new Error(buildAPIRecoveryMessage(path, '本地 API 未连接', lastAttempt)); } +async function pollAsyncJob(job, { base = activeApiBase, timeoutMs = 120000, intervalMs = 500 } = {}) { + if (!job?.poll_path || !job?.access_token) throw new Error('Async job response missing poll capability'); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const resp = await fetch(`${base}${job.poll_path}`, { + headers: { Authorization: `Bearer ${job.access_token}` }, + }); + const data = await parseApiResponse(resp); + if (!resp.ok) throw new Error(buildAPIRecoveryMessage(job.poll_path, data?.error || `Job poll failed (${resp.status})`)); + if (data.status === 'completed') return data.result || data; + if (data.status === 'failed') throw new Error(data.error || 'Async job failed'); + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + throw new Error(buildAPIRecoveryMessage(job.poll_path, 'Async job timed out')); +} + async function fetchJson(path) { let lastError = null; let lastAttempt = null; @@ -501,6 +518,7 @@ window.JyotishAPI = { computeKakshya, computeBhavaBala, computeTransitTriggers, + pollAsyncJob, // AI 解读 aiReading, aiFullReading, diff --git a/jyotish-app/auth.js b/jyotish-app/auth.js index d061ddfd..349c19dd 100644 --- a/jyotish-app/auth.js +++ b/jyotish-app/auth.js @@ -12,7 +12,6 @@ import { escapeAttr, escapeHtml } from './security.js'; const API_BASE = ''; // 同域部署,留空;Capacitor 打包时改为服务器地址 const TOKEN_KEY = 'jyotish_auth_token'; const USER_KEY = 'jyotish_auth_user'; -const API_BASE_KEY = 'jyotish_api_base'; // ============================================================================ // 状态 @@ -61,7 +60,7 @@ export function getUser() { return _user; } export function isLoggedIn() { return !!_token && !!_user; } export function getApiBase() { - return window.JYOTISH_API_BASE || import.meta.env?.VITE_JYOTISH_API_BASE || localStorage.getItem(API_BASE_KEY) || API_BASE; + return window.JYOTISH_API_BASE || import.meta.env?.VITE_JYOTISH_API_BASE || API_BASE; } export function onAuthChange(cb) { _onAuthChange = cb; } diff --git a/pyproject.toml b/pyproject.toml index deecac3e..8404ede8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ keywords = ["jyotish", "vedic", "astrology", "astronomy", "dasha", "varga", "nakshatra", "shadbala", "panchanga", "horoscope", "birth-chart"] dependencies = [ "pyswisseph>=2.8", + "timezonefinder>=6.5", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index c129a6b8..ab9e0673 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ # 核心:Swiss Ephemeris 天文计算库(必需) pyswisseph +timezonefinder>=6.5 # 以下为标准库,无需安装(仅供参考): # argparse, json, sys, os, csv, math, sqlite3 diff --git a/scripts/domain_calculation_service.py b/scripts/domain_calculation_service.py new file mode 100644 index 00000000..f778cf91 --- /dev/null +++ b/scripts/domain_calculation_service.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Canonical calculation service shared by CLI, REST, and MCP adapters.""" + +from __future__ import annotations + +import hashlib +import json +import math +import threading +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo + +import swisseph as swe +from ayanamsa_utils import apply_ayanamsa, normalize_ayanamsa_name +from dasha_analyzer import build_dasha_timeline, lon_to_nakshatra +from jyotish_engine import SIGNS, compute_chart_data +from sade_sati import calc_sade_sati_complete + +CONTRACT_VERSION = "1.0.0" +_SWISSEPH_LOCK = threading.RLock() +_PLANET_IDS = {"Saturn": swe.SATURN} + + +class CalculationError(ValueError): + pass + + +class TimezoneInferenceError(CalculationError): + pass + + +def _canonical_hash(payload: dict[str, Any]) -> str: + encoded = json.dumps( + payload, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _lookup_timezone_name(lat: float, lon: float) -> str | None: + try: + from timezonefinder import TimezoneFinder + except ImportError as exc: + raise TimezoneInferenceError("timezone inference dependency unavailable") from exc + return TimezoneFinder().timezone_at(lng=lon, lat=lat) + + +def infer_timezone_offset(*, lat: float, lon: float, local_datetime: datetime) -> float: + if not (-90 <= lat <= 90 and -180 <= lon <= 180): + raise TimezoneInferenceError("timezone inference received invalid coordinates") + tz_name = _lookup_timezone_name(lat, lon) + if not tz_name: + raise TimezoneInferenceError("timezone inference returned no IANA zone") + try: + offset = local_datetime.replace(tzinfo=ZoneInfo(tz_name)).utcoffset() + except Exception as exc: + raise TimezoneInferenceError("timezone inference failed for IANA zone") from exc + if offset is None: + raise TimezoneInferenceError("timezone inference returned no UTC offset") + return offset.total_seconds() / 3600.0 + + +def _normalized_request(payload: dict[str, Any]) -> dict[str, Any]: + requested_node = str(payload.get("node_mode", payload.get("nodeMode", "mean"))).lower() + if requested_node not in {"mean", "true"}: + raise CalculationError("node_mode must be mean or true") + ayanamsa = normalize_ayanamsa_name(payload.get("ayanamsa", "lahiri")) + local_dt = datetime( + int(payload["year"]), + int(payload["month"]), + int(payload["day"]), + int(float(payload.get("hour", 0))), + int(float(payload.get("minute", 0))), + int(float(payload.get("second", 0))), + ) + lat = float(payload["lat"]) + lon = float(payload["lon"]) + tz_requested = payload.get("tz") + timezone_source = "explicit_offset" + if tz_requested in {None, ""}: + tz = infer_timezone_offset(lat=lat, lon=lon, local_datetime=local_dt) + timezone_source = "iana_inferred" + else: + tz = float(tz_requested) + if not math.isfinite(tz) or not -14 <= tz <= 14: + raise CalculationError("tz must be a finite offset between -14 and 14") + return { + "year": local_dt.year, + "month": local_dt.month, + "day": local_dt.day, + "hour": int(float(payload.get("hour", 0))), + "minute": int(float(payload.get("minute", 0))), + "second": int(float(payload.get("second", 0))), + "lat": lat, + "lon": lon, + "tz": tz, + "timezone_source": timezone_source, + "ayanamsa": ayanamsa, + "node_mode": requested_node, + } + + +def _contract(requested: dict[str, Any], effective: dict[str, Any], *, algorithm: str) -> dict[str, Any]: + return { + "contract_version": CONTRACT_VERSION, + "algorithm": algorithm, + "requested": requested, + "effective": effective, + } + + +def compute_chart(payload: dict[str, Any]) -> dict[str, Any]: + request = _normalized_request(payload) + with _SWISSEPH_LOCK: + chart, _asc_idx, _jd, _ayanamsa = compute_chart_data( + request["year"], + request["month"], + request["day"], + request["hour"], + request["minute"], + request["lat"], + request["lon"], + request["tz"], + node_mode=request["node_mode"], + second=request["second"], + ayanamsa_name=request["ayanamsa"], + ) + if not isinstance(chart, dict): + raise CalculationError("canonical chart calculation failed") + + for planet in chart.get("planets", {}).values(): + if not isinstance(planet, dict) or "error" in planet: + continue + planet.setdefault("lon", planet.get("degree_raw", planet.get("degree"))) + if planet.get("sign") in SIGNS: + planet.setdefault("sign_idx", SIGNS.index(planet["sign"])) + + birth = chart.get("birth_info", {}) + effective = { + "ayanamsa": birth.get("ayanamsa_name", request["ayanamsa"]), + "node_mode": birth.get("node_mode", request["node_mode"]), + "timezone_offset": request["tz"], + "timezone_source": request["timezone_source"], + "ephemeris_source": "swisseph_calc_ut", + "ephemeris_flags_verified": False, + } + requested = { + "ayanamsa": payload.get("ayanamsa", "lahiri"), + "node_mode": payload.get("node_mode", payload.get("nodeMode", "mean")), + "timezone_offset": payload.get("tz"), + } + contract = _contract(requested, effective, algorithm="sidereal_natal_chart") + hash_payload = { + "contract": contract, + "birth": birth, + "ascendant": chart.get("ascendant"), + "planets": chart.get("planets"), + } + chart["calculation_contract"] = contract + chart["result_hash"] = _canonical_hash(hash_payload) + return chart + + +def compute_vimshottari_timeline( + *, birth_dt: datetime, moon_lon: float, current_date: datetime | None = None +) -> dict[str, Any]: + nak_info, progress, pada = lon_to_nakshatra(float(moon_lon) % 360) + timeline, elapsed, remaining, start_lord = build_dasha_timeline( + birth_dt.strftime("%Y-%m-%d"), nak_info, progress + ) + periods = [ + { + "lord": period["lord"], + "years": period["years"], + "start": period["start"].strftime("%Y-%m-%d"), + "end": period["end"].strftime("%Y-%m-%d"), + } + for period in timeline + ] + contract = _contract( + {"moon_longitude": float(moon_lon) % 360}, + {"year_basis_days": 365.25, "nakshatra": nak_info[0], "pada": pada}, + algorithm="vimshottari_birth_balance", + ) + result = { + "periods": periods, + "birth_balance": { + "lord": start_lord, + "elapsed_years": elapsed, + "remaining_years": remaining, + }, + "calculation_contract": contract, + } + result["result_hash"] = _canonical_hash(result) + return result +def compute_transit_longitude( + *, planet: str, reference_date: str, tz: float, ayanamsa: str = "lahiri" +) -> dict[str, Any]: + if planet not in _PLANET_IDS: + raise CalculationError(f"unsupported transit planet: {planet}") + try: + local_dt = datetime.strptime(reference_date[:10], "%Y-%m-%d").replace(hour=12) + except (TypeError, ValueError) as exc: + raise CalculationError("reference_date must be YYYY-MM-DD") from exc + ayanamsa_name = normalize_ayanamsa_name(ayanamsa) + with _SWISSEPH_LOCK: + apply_ayanamsa(ayanamsa_name, swe) + jd = swe.julday( + local_dt.year, + local_dt.month, + local_dt.day, + 12.0 - float(tz), + ) + ayanamsa_value = swe.get_ayanamsa(jd) + position, flags = swe.calc_ut(jd, _PLANET_IDS[planet]) + longitude = (position[0] - ayanamsa_value) % 360 + return { + "planet": planet, + "longitude": longitude, + "reference_date": reference_date[:10], + "ayanamsa": ayanamsa_name, + "timezone_offset": float(tz), + "swisseph_return_flags": int(flags), + "data_layer": "true_transit_positions", + } + + +def compute_sade_sati( + *, + moon_degree: float, + asc_degree: float, + reference_date: str, + tz: float, + ayanamsa: str = "lahiri", +) -> dict[str, Any]: + transit = compute_transit_longitude( + planet="Saturn", + reference_date=reference_date, + tz=tz, + ayanamsa=ayanamsa, + ) + result = calc_sade_sati_complete( + float(moon_degree) % 360, + float(asc_degree) % 360, + transit["longitude"], + datetime.strptime(reference_date[:10], "%Y-%m-%d"), + ) + result["transit_saturn_lon"] = transit["longitude"] + result["provenance"] = transit + result["calculation_contract"] = _contract( + {"reference_date": reference_date[:10], "ayanamsa": ayanamsa, "tz": tz}, + transit, + algorithm="sade_sati_true_saturn_transit", + ) + result["result_hash"] = _canonical_hash(result) + return result diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index e0767f59..ed23e3e1 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -15,10 +15,12 @@ import json, sys, os, math import importlib.util import hashlib import re +import secrets import threading import time +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta -from http.server import HTTPServer, BaseHTTPRequestHandler +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlparse @@ -43,6 +45,25 @@ _LOCAL_MODULE_CACHE = {} _API_CHART_CACHE_SCOPE = 'api_chart_response' _HIGH_RIGOR_JOB_SCOPE = 'high_rigor_workflow' _UNIFIED_CONSULTATION_ORCHESTRATOR = UnifiedConsultationOrchestrator() +_ASYNC_JOB_WORKERS = max(int(os.environ.get('JYOTISH_ASYNC_JOB_WORKERS', '2')), 1) +_ASYNC_JOB_QUEUE_SIZE = max(int(os.environ.get('JYOTISH_ASYNC_JOB_QUEUE_SIZE', '8')), 0) +_ASYNC_JOB_EXECUTOR = ThreadPoolExecutor( + max_workers=_ASYNC_JOB_WORKERS, + thread_name_prefix='jyotish-job', +) +_ASYNC_JOB_CAPACITY = threading.BoundedSemaphore(_ASYNC_JOB_WORKERS + _ASYNC_JOB_QUEUE_SIZE) + + +def _submit_background_job(callback): + if not _ASYNC_JOB_CAPACITY.acquire(blocking=False): + raise JobQueueFull('Async job queue is full') + try: + future = _ASYNC_JOB_EXECUTOR.submit(callback) + except Exception: + _ASYNC_JOB_CAPACITY.release() + raise + future.add_done_callback(lambda _future: _ASYNC_JOB_CAPACITY.release()) + return future def _western_evidence_packet_from_body(body: dict, route_packet: dict) -> dict | None: @@ -467,29 +488,66 @@ def _async_job_path(scope: str, job_id: str) -> Path: return _async_job_dir(scope) / f'{job_id}.json' -def _load_high_rigor_job_record(job_id: str) -> dict | None: - return _load_async_job_record(_HIGH_RIGOR_JOB_SCOPE, job_id) +def _async_job_ttl_seconds() -> float: + raw = str(os.environ.get('JYOTISH_ASYNC_JOB_TTL_SECONDS', '3600')).strip() + try: + return max(float(raw), 1.0) + except ValueError: + return 3600.0 + + +def _new_async_job_identity(prefix: str) -> dict: + return { + 'job_id': f'{prefix}_{secrets.token_hex(16)}', + 'access_token': secrets.token_urlsafe(32), + } + + +def _access_token_hash(token: str) -> str: + return hashlib.sha256(token.encode('utf-8')).hexdigest() + + +def _load_high_rigor_job_record(job_id: str, *, access_token: str = '') -> dict | None: + return _load_async_job_record( + _HIGH_RIGOR_JOB_SCOPE, + job_id, + access_token=access_token, + ) def _write_high_rigor_job_record(job_id: str, payload: dict) -> dict: return _write_async_job_record(_HIGH_RIGOR_JOB_SCOPE, job_id, payload) -def _load_async_job_record(scope: str, job_id: str) -> dict | None: +def _load_async_job_record(scope: str, job_id: str, *, access_token: str = '') -> dict | None: path = _async_job_path(scope, job_id) if not path.exists(): return None try: - return json.loads(path.read_text(encoding='utf-8')) + record = json.loads(path.read_text(encoding='utf-8')) except (OSError, json.JSONDecodeError): return None + expires_at = record.get('expires_at_unix') + if isinstance(expires_at, (int, float)) and time.time() >= float(expires_at): + try: + path.unlink() + except OSError: + pass + return None + expected = record.get('access_token_hash') + if not isinstance(expected, str) or not access_token: + raise JobAccessDenied('Async job access token required') + if not secrets.compare_digest(expected, _access_token_hash(access_token)): + raise JobAccessDenied('Async job access token invalid') + return record def _write_async_job_record(scope: str, job_id: str, payload: dict) -> dict: - _async_job_path(scope, job_id).write_text( - json.dumps(payload, ensure_ascii=False, sort_keys=True), - encoding='utf-8', - ) + path = _async_job_path(scope, job_id) + temp_path = path.with_suffix(f'.{secrets.token_hex(8)}.tmp') + temp_path.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True), encoding='utf-8') + os.chmod(temp_path, 0o600) + os.replace(temp_path, path) return payload @@ -809,6 +867,22 @@ class BadRequest(ValueError): """Client-side request validation failed.""" +class Forbidden(PermissionError): + """Request failed the local API trust boundary.""" + + +class UnsupportedMediaType(ValueError): + """Request body media type is not supported.""" + + +class JobAccessDenied(PermissionError): + """Async job capability token is missing or invalid.""" + + +class JobQueueFull(RuntimeError): + """Bounded async worker queue has no remaining capacity.""" + + class JyotishAPIHandler(BaseHTTPRequestHandler): server_version = 'JyotishAPI/6.9.14' @@ -832,6 +906,24 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): if origin in allowed: self.send_header('Access-Control-Allow-Origin', origin) + def _enforce_request_security(self, *, require_json=False): + origin = self.headers.get('Origin') + allowed = getattr(self.server, 'allowed_origins', DEFAULT_ALLOWED_ORIGINS) + if origin and origin not in allowed: + raise Forbidden('Origin is not allowed') + host = (self.headers.get('Host') or '').split(':', 1)[0].strip('[]').lower() + if host and host not in {'localhost', '127.0.0.1', '::1'}: + raise Forbidden('Host is not allowed') + if require_json: + content_type = (self.headers.get('Content-Type') or '').split(';', 1)[0].strip().lower() + if content_type != 'application/json': + raise UnsupportedMediaType('Content-Type must be application/json') + + def _job_access_token(self): + authorization = self.headers.get('Authorization') or '' + scheme, _, token = authorization.partition(' ') + return token.strip() if scheme.lower() == 'bearer' else '' + def _vedastro_status(self): adapter = _load_local_module('vedastro_service_adapter') endpoint = os.environ.get('VEDASTRO_API_ENDPOINT', '').strip() @@ -877,11 +969,16 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): } def do_OPTIONS(self): - self._json({}) + try: + self._enforce_request_security() + self._json({}) + except Forbidden as exc: + self._error_json(str(exc), 403, 'ERR_FORBIDDEN') def do_GET(self): path = urlparse(self.path).path try: + self._enforce_request_security() if path == '/api/health': swisseph_available = False swisseph_version = None @@ -937,6 +1034,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): self._json(self._real_case_revalidation()) else: self._error_json('Not found', 404, 'ERR_NOT_FOUND') + except (Forbidden, JobAccessDenied) as exc: + self._error_json(str(exc), 403, 'ERR_FORBIDDEN') except Exception: import logging logging.exception("[api_server] GET request failed for %s", path) @@ -945,6 +1044,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): def do_POST(self): path = urlparse(self.path).path try: + self._enforce_request_security(require_json=True) body = self._read_json_body() if path == '/api/chart': result = self._compute_chart(body) @@ -1083,6 +1183,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): self._error_json(f'Unknown endpoint: {path}', 404, 'ERR_NOT_FOUND') except BadRequest as e: self._error_json(str(e), 400, 'ERR_BAD_REQUEST') + except Forbidden as exc: + self._error_json(str(exc), 403, 'ERR_FORBIDDEN') + except UnsupportedMediaType as exc: + self._error_json(str(exc), 415, 'ERR_UNSUPPORTED_MEDIA_TYPE') + except JobQueueFull as exc: + self._error_json(str(exc), 503, 'ERR_JOB_QUEUE_FULL') except Exception: import logging logging.exception("[api_server] request failed for %s", path) @@ -1122,13 +1228,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): tz = body.get('tz') if tz is not None and tz != "": return self._get_float(body, 'tz', 8, -14, 14) - from timezone_utils import infer_timezone from datetime import datetime try: dt = datetime(int(year), int(month), int(day), int(hour), int(minute), int(second)) - except Exception: - dt = datetime.utcnow() - return infer_timezone(lat, lon, dt) + except (TypeError, ValueError) as exc: + raise BadRequest('Invalid birth date') from exc + try: + calculation_service = _load_local_module('domain_calculation_service') + return calculation_service.infer_timezone_offset( + lat=lat, + lon=lon, + local_datetime=dt, + ) + except ValueError as exc: + raise BadRequest(str(exc)) from exc def _get_float(self, body, key, default, min_value=None, max_value=None): value = body.get(key, default) @@ -1960,7 +2073,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): } def _enqueue_high_rigor_job(self, body): - job_id = f'hrw_{datetime.utcnow().strftime("%Y%m%d%H%M%S%f")}' + identity = _new_async_job_identity('hrw') + job_id = identity['job_id'] queued_at = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') poll_path = f'/api/high_rigor_workflow/jobs/{job_id}' record = { @@ -1972,13 +2086,18 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'queued_at': queued_at, 'poll_path': poll_path, 'scope': _HIGH_RIGOR_JOB_SCOPE, + 'access_token': identity['access_token'], + 'expires_at_unix': time.time() + _async_job_ttl_seconds(), } - _write_high_rigor_job_record(job_id, record) + stored_record = dict(record) + stored_record.pop('access_token') + stored_record['access_token_hash'] = _access_token_hash(identity['access_token']) + _write_high_rigor_job_record(job_id, stored_record) body_copy = dict(body or {}) def _run_job() -> None: - running = dict(record) + running = dict(stored_record) running['status'] = 'running' running['started_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') _write_high_rigor_job_record(job_id, running) @@ -1998,15 +2117,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): failed['error'] = str(exc) _write_high_rigor_job_record(job_id, failed) - threading.Thread( - target=_run_job, - name=f'high-rigor-job-{job_id}', - daemon=True, - ).start() + _submit_background_job(_run_job) return record def _enqueue_async_job(self, *, scope, endpoint, job_prefix, poll_base, compute_fn): - job_id = f'{job_prefix}_{datetime.utcnow().strftime("%Y%m%d%H%M%S%f")}' + identity = _new_async_job_identity(job_prefix) + job_id = identity['job_id'] queued_at = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') poll_path = f'{poll_base}/{job_id}' record = { @@ -2018,11 +2134,16 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'queued_at': queued_at, 'poll_path': poll_path, 'scope': scope, + 'access_token': identity['access_token'], + 'expires_at_unix': time.time() + _async_job_ttl_seconds(), } - _write_async_job_record(scope, job_id, record) + stored_record = dict(record) + stored_record.pop('access_token') + stored_record['access_token_hash'] = _access_token_hash(identity['access_token']) + _write_async_job_record(scope, job_id, stored_record) def _run_job() -> None: - running = dict(record) + running = dict(stored_record) running['status'] = 'running' running['started_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') _write_async_job_record(scope, job_id, running) @@ -2042,18 +2163,18 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): failed['error'] = str(exc) _write_async_job_record(scope, job_id, failed) - threading.Thread( - target=_run_job, - name=f'{job_prefix}-job-{job_id}', - daemon=True, - ).start() + _submit_background_job(_run_job) return record def _get_high_rigor_job(self, job_id): - return _load_high_rigor_job_record(job_id) + return _load_high_rigor_job_record(job_id, access_token=self._job_access_token()) def _get_chart_job(self, job_id): - return _load_async_job_record(_API_CHART_CACHE_SCOPE, job_id) + return _load_async_job_record( + _API_CHART_CACHE_SCOPE, + job_id, + access_token=self._job_access_token(), + ) def _high_rigor_birth_payload(self, body): required = ('year', 'month', 'day', 'hour', 'minute', 'lat', 'lon') @@ -3913,29 +4034,6 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): errors.append(str(e)) raise BadRequest('PDF has no extractable text; OCR is not supported yet') - def _calc_vimshottari_periods(self, birth_dt, moon_lon): - extended_dashas = _load_local_module('extended_dashas') - DASHA_ORDER = extended_dashas.DASHA_ORDER - YEAR_DAYS = extended_dashas.YEAR_DAYS - dasha_years = [7, 20, 6, 10, 7, 18, 16, 19, 17] - nak_size = 360 / 27 - nak_idx = int(moon_lon / nak_size) % 27 - start_idx = nak_idx % len(DASHA_ORDER) - current = birth_dt - periods = [] - for i in range(len(DASHA_ORDER)): - idx = (start_idx + i) % len(DASHA_ORDER) - years = dasha_years[idx] - end_date = current + timedelta(days=years * YEAR_DAYS) - periods.append({ - 'lord': DASHA_ORDER[idx], - 'years': years, - 'start': current.strftime('%Y-%m-%d'), - 'end': end_date.strftime('%Y-%m-%d'), - }) - current = end_date - return periods - def _compute_chart(self, body): if body.get('async') or body.get('enqueue'): return self._enqueue_chart_job(body) @@ -3975,78 +4073,69 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): raise BadRequest('Invalid birth date') from e try: - import swisseph as swe - swe.set_ephe_path(os.path.join(SCRIPTS_DIR, '..', 'swiss_ephemeris')) + calculation_service = _load_local_module('domain_calculation_service') + canonical_chart = calculation_service.compute_chart({ + 'year': year, + 'month': month, + 'day': day, + 'hour': hour, + 'minute': minute, + 'second': second, + 'lat': lat, + 'lon': lon, + 'tz': tz, + 'ayanamsa': body.get('ayanamsa', 'lahiri'), + 'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')), + }) + canonical_birth = canonical_chart['birth_info'] + planets_data = canonical_chart['planets'] + ascendant_data = canonical_chart['ascendant'] + asc_lon = float(ascendant_data['lon']) + asc_sign = ascendant_data['sign'] + asc_sign_idx = SIGNS.index(asc_sign) birth_hour_decimal = self._birth_hour_decimal(hour, minute, second) - hour_ut = birth_hour_decimal - tz - jd = swe.julday(year, month, day, hour_ut) - ayanamsa_name = body.get('ayanamsa', 'lahiri') - try: - from jyotish_engine import _apply_ayanamsa, _ayanamsa_display_name - _apply_ayanamsa(ayanamsa_name) - ayanamsa_display = _ayanamsa_display_name(ayanamsa_name) - except ImportError: - swe.set_sid_mode(swe.SIDM_LAHIRI, 0, 0) - ayanamsa_name = 'lahiri' - ayanamsa_display = 'Lahiri' - ayanamsa = swe.get_ayanamsa(jd) + jd = float(canonical_birth['julian_day']) + ayanamsa = float(canonical_birth['ayanamsa']) + ayanamsa_name = canonical_birth['ayanamsa_name'] + ayanamsa_display = canonical_birth['ayanamsa_display'] - planets_data = {} - planet_ids = {'Sun': 0, 'Moon': 1, 'Mars': 4, 'Mercury': 2, 'Jupiter': 5, 'Venus': 3, 'Saturn': 6, 'Rahu': 10, 'Ketu': 20} - planet_names_rev = {v: k for k, v in planet_ids.items()} - - for pid, pname in planet_names_rev.items(): - if pid == 20: - rahu_result, _ = swe.calc_ut(jd, 10) - planet_lon = (rahu_result[0] - ayanamsa + 180) % 360 - else: - result, _ = swe.calc_ut(jd, pid) - planet_lon = (result[0] - ayanamsa) % 360 - sign_idx = int(planet_lon / 30) % 12 - planets_data[pname] = {'lon': planet_lon, 'sign_idx': sign_idx, 'sign': SIGNS[sign_idx], 'degree': planet_lon % 30} - - # Ascendant - asc_tropical = swe.houses_ex(jd, lat, lon, b'E')[0][0] % 360 - asc_lon = (asc_tropical - ayanamsa) % 360 - asc_sign_idx = int(asc_lon / 30) % 12 - asc_sign = SIGNS[asc_sign_idx] - - # Houses houses = {} for h in range(1, 13): - s = (asc_sign_idx + h - 1) % 12 - houses[h] = {'sign': SIGNS[s], 'sign_idx': s} - - # Planet houses - for pn, pd in planets_data.items(): - pd['house'] = ((pd['sign_idx'] - asc_sign_idx) % 12) + 1 - - # Dasha (simplified Vimshottari) - moon_lon = planets_data['Moon']['lon'] - nak_size = 360/27 - nak_idx = int(moon_lon / nak_size) - dasha_lords = ['Ketu','Venus','Sun','Moon','Mars','Rahu','Jupiter','Saturn','Mercury'] - dasha_years = [7,20,6,10,7,18,16,19,17] - nak_lord_idx = nak_idx % 9 - md_lord = dasha_lords[nak_lord_idx] - total_years = dasha_years[nak_lord_idx] - elapsed = (moon_lon % nak_size) / nak_size * total_years - remaining = total_years - elapsed + house = canonical_chart.get('houses', {}).get(f'house_{h}', {}) + sign = house.get('cusp_sign', SIGNS[(asc_sign_idx + h - 1) % 12]) + houses[h] = { + 'sign': sign, + 'sign_idx': SIGNS.index(sign), + 'cusp_degree': house.get('cusp_degree'), + } + moon_lon = float(planets_data['Moon']['lon']) birth_dt = datetime(year, month, day, int(hour), int(minute), int(second)) - elapsed_days = elapsed * 365.25636 - dasha_start = birth_dt - timedelta(days=elapsed_days) if elapsed_days < 365*120 else birth_dt + canonical_dasha = calculation_service.compute_vimshottari_timeline( + birth_dt=birth_dt, + moon_lon=moon_lon, + current_date=birth_dt, + ) + dasha_balance = canonical_dasha['birth_balance'] + md_lord = dasha_balance['lord'] + remaining = dasha_balance['remaining_years'] + total_years = canonical_dasha['periods'][0]['years'] + dasha_start = datetime.strptime(canonical_dasha['periods'][0]['start'], '%Y-%m-%d') - # Yoga detection yogas = self._detect_yogas(planets_data, asc_sign_idx) - - # Sade Sati - from sade_sati import calc_sade_sati_complete - # Transit Saturn (approximate) - saturn_year_progress = (year - 2026) * 12 / 30 # ~12 signs in 30 years - transit_saturn_sign = (planets_data['Saturn']['sign_idx'] + int(saturn_year_progress)) % 12 - transit_saturn_lon = transit_saturn_sign * 30 + 15 - sade_sati = calc_sade_sati_complete(moon_lon, asc_lon, transit_saturn_lon) + reference_date = ( + body.get('transit_date') + or body.get('today') + or body.get('current_date') + or datetime.now().strftime('%Y-%m-%d') + ) + sade_sati = calculation_service.compute_sade_sati( + moon_degree=moon_lon, + asc_degree=asc_lon, + reference_date=reference_date, + tz=tz, + ayanamsa=ayanamsa_name, + ) # Dasha清单 extended_dashas = _load_local_module('extended_dashas') @@ -4120,7 +4209,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'ayanamsa': round(ayanamsa, 4), 'ayanamsa_name': ayanamsa_name, 'ayanamsa_display': ayanamsa_display, - 'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')), + 'node_mode': canonical_chart['calculation_contract']['effective']['node_mode'], }, 'ascendant': { 'sign': asc_sign, @@ -4135,6 +4224,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'remaining_years': round(remaining, 2), 'total_years': total_years, 'start_date': dasha_start.isoformat() if hasattr(dasha_start, 'isoformat') else str(dasha_start), + 'periods': canonical_dasha['periods'], + 'birth_balance': canonical_dasha['birth_balance'], + 'calculation_contract': canonical_dasha['calculation_contract'], + 'result_hash': canonical_dasha['result_hash'], }, 'yogas': yogas, 'sade_sati': sade_sati, @@ -4143,6 +4236,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'special_lagnas': special_lagnas, 'available_dashas': dasha_list, 'dasha_count': len(dasha_list), + 'calculation_contract': canonical_chart['calculation_contract'], + 'result_hash': canonical_chart['result_hash'], } result['modules'] = { 'chart': { @@ -4150,6 +4245,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'ascendant': result['ascendant'], 'houses': result['houses'], 'birth_info': result['birth'], + 'calculation_contract': result['calculation_contract'], + 'result_hash': result['result_hash'], }, 'dasha': result['dasha'], 'shadbala': {'planets': sb.get('planets', {})} if 'sb' in locals() and isinstance(sb, dict) else {}, @@ -4740,9 +4837,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): tithi_num = self._get_int(body, 'tithi_num', 1, 1, 30) vimshottari_analysis = None + canonical_dasha = None if dasha_key == 'vimshottari': - periods = self._calc_vimshottari_periods(birth_dt, moon_lon) - precision = 'calculator' + calculation_service = _load_local_module('domain_calculation_service') + canonical_dasha = calculation_service.compute_vimshottari_timeline( + birth_dt=birth_dt, + moon_lon=moon_lon, + current_date=( + self._parse_optional_date(body.get('today') or body.get('current_date')) + if body.get('today') or body.get('current_date') + else None + ), + ) + periods = canonical_dasha['periods'] + precision = 'canonical_birth_balance' vimshottari_analysis = self._compute_vimshottari_analysis_layer( birth_dt, moon_lon, @@ -4778,6 +4886,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): if vimshottari_analysis: result['vimshottari_analysis'] = vimshottari_analysis result['fragment_sources'] = ['dasha_analyzer.py', 'dasha_calculator_enhanced.py'] + if canonical_dasha: + result['birth_balance'] = canonical_dasha['birth_balance'] + result['calculation_contract'] = canonical_dasha['calculation_contract'] + result['result_hash'] = canonical_dasha['result_hash'] return result def _compute_vimshottari_analysis_layer(self, birth_dt, moon_lon, current_date=None): @@ -4853,11 +4965,19 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): } def _compute_sade_sati(self, body): - from sade_sati import calc_sade_sati_complete - return calc_sade_sati_complete( - self._normalize_degree(body, 'moon_degree', 0), - self._normalize_degree(body, 'asc_degree', 0), - self._normalize_degree(body, 'saturn_degree', 0), + calculation_service = _load_local_module('domain_calculation_service') + reference_date = ( + body.get('reference_date') + or body.get('transit_date') + or body.get('current_date') + or datetime.now().strftime('%Y-%m-%d') + ) + return calculation_service.compute_sade_sati( + moon_degree=self._normalize_degree(body, 'moon_degree', 0), + asc_degree=self._normalize_degree(body, 'asc_degree', 0), + reference_date=reference_date, + tz=self._get_float(body, 'tz', 0, -14, 14), + ayanamsa=body.get('ayanamsa', 'lahiri'), ) def _compute_pmc(self, body): @@ -7448,7 +7568,8 @@ def _parse_allowed_origins(value): def start_server(port=5200, host='127.0.0.1', allowed_origins=None): - server = HTTPServer((host, port), JyotishAPIHandler) + server = ThreadingHTTPServer((host, port), JyotishAPIHandler) + server.daemon_threads = True server.allowed_origins = allowed_origins or DEFAULT_ALLOWED_ORIGINS print(f'Jyotish API v6.9.14 running on http://{host}:{port}') print(f' CORS origins: {", ".join(sorted(server.allowed_origins))}') diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 2f83deb3..6a663f6b 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -735,12 +735,24 @@ def _birth_datetime_from_args(args): def _compute_chart_from_args(args): - return compute_chart_data( - args.year, args.month, args.day, args.hour, args.minute, - args.lat, args.lon, args.tz, getattr(args, 'node_mode', 'mean'), - second=_arg_second(args), - ayanamsa_name=_current_ayanamsa_name(args), - ) + from domain_calculation_service import compute_chart + + result = compute_chart({ + 'year': args.year, + 'month': args.month, + 'day': args.day, + 'hour': args.hour, + 'minute': args.minute, + 'second': _arg_second(args), + 'lat': args.lat, + 'lon': args.lon, + 'tz': args.tz, + 'node_mode': getattr(args, 'node_mode', 'mean'), + 'ayanamsa': _current_ayanamsa_name(args), + }) + asc_idx = SIGNS.index(result['ascendant']['sign']) + birth = result['birth_info'] + return result, asc_idx, birth['julian_day'], birth['ayanamsa'] def _current_ayanamsa_name(args=None): diff --git a/scripts/report_builder.py b/scripts/report_builder.py index bc0c22ea..4d791275 100644 --- a/scripts/report_builder.py +++ b/scripts/report_builder.py @@ -29,6 +29,7 @@ import sys import re import glob import argparse +from urllib.parse import urlparse try: import markdown @@ -352,6 +353,13 @@ def build_section(num, title, md_text): """ +def is_allowed_report_resource_url(url, *, report_url): + if url == report_url: + return True + parsed = urlparse(url) + return parsed.scheme in {'data', 'about', 'blob'} + + def _html_to_pdf(html_path, pdf_path): """Convert HTML to PDF using Playwright headless Chromium.""" try: @@ -364,14 +372,25 @@ def _html_to_pdf(html_path, pdf_path): print(" Launching headless Chromium...") with sync_playwright() as p: browser = p.chromium.launch(headless=True) - page = browser.new_page() - page.goto(f"file://{os.path.abspath(html_path)}", wait_until="networkidle") + context = browser.new_context(java_script_enabled=False) + page = context.new_page() + report_url = f"file://{os.path.abspath(html_path)}" + page.route( + "**/*", + lambda route: ( + route.continue_() + if is_allowed_report_resource_url(route.request.url, report_url=report_url) + else route.abort() + ), + ) + page.goto(report_url, wait_until="networkidle") page.pdf( path=pdf_path, format="A4", print_background=True, margin={"top": "22mm", "bottom": "24mm", "left": "20mm", "right": "20mm"}, ) + context.close() browser.close() size_kb = os.path.getsize(pdf_path) / 1024 diff --git a/scripts/three_engine_parity_runner.py b/scripts/three_engine_parity_runner.py new file mode 100644 index 00000000..f1800b79 --- /dev/null +++ b/scripts/three_engine_parity_runner.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Capture a public same-chart parity packet without overstating oracle closure.""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +from domain_calculation_service import compute_chart + + +ROOT = Path(__file__).resolve().parents[1] +PYJHORA_ARTIFACT = ROOT / "references/oracle/artifacts/pyjhora_steve_jobs_dasha_stdout_20260627.txt" +JYOTISHGANIT_ROOT = ROOT / "references/open_source_sources/jyotishganit" + +PUBLIC_CASE = { + "case_id": "steve_jobs_public_1955_lahiri", + "year": 1955, + "month": 2, + "day": 24, + "hour": 19, + "minute": 15, + "second": 0, + "lat": 37.7749, + "lon": -122.4194, + "tz": -8.0, + "ayanamsa": "lahiri", + "node_mode": "mean", +} + + +def _write_json(path: Path, value: dict[str, Any]) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") + return path + + +def _capture_jyotishganit_raw(output_dir: Path) -> tuple[dict[str, Any], str]: + sys.path.insert(0, str(JYOTISHGANIT_ROOT)) + try: + from jyotishganit import calculate_birth_chart, get_birth_chart_json + + chart = calculate_birth_chart( + datetime( + PUBLIC_CASE["year"], + PUBLIC_CASE["month"], + PUBLIC_CASE["day"], + PUBLIC_CASE["hour"], + PUBLIC_CASE["minute"], + PUBLIC_CASE["second"], + ), + PUBLIC_CASE["lat"], + PUBLIC_CASE["lon"], + PUBLIC_CASE["tz"], + location_name="San Francisco, CA", + name="Steve Jobs (public benchmark)", + ) + raw = get_birth_chart_json(chart) + path = _write_json(output_dir / "jyotishganit_raw.json", raw) + return raw, str(path) + except Exception as exc: + return {"error": f"{exc.__class__.__name__}: {exc}"}, "" + finally: + try: + sys.path.remove(str(JYOTISHGANIT_ROOT)) + except ValueError: + pass + + +def _vedastro_state(*, allow_network: bool) -> dict[str, Any]: + if not allow_network: + return { + "status": "blocked", + "official_raw_response_path": "", + "reason": "network_disabled_for_public_replay", + } + return { + "status": "blocked", + "official_raw_response_path": "", + "reason": "official_runner_requires_explicit_raw_capture_workflow", + } + + +def build_public_case_replay(*, output_dir: Path, allow_vedastro_network: bool = False) -> dict[str, Any]: + output_dir.mkdir(parents=True, exist_ok=True) + local = compute_chart(PUBLIC_CASE) + jyotishganit_raw, jyotishganit_path = _capture_jyotishganit_raw(output_dir) + pyjhora_available = PYJHORA_ARTIFACT.is_file() + vedastro = _vedastro_state(allow_network=allow_vedastro_network) + + rows = [ + { + "section": "D1", + "field": "Sun.longitude", + "local_value": local["planets"]["Sun"]["lon"], + "oracle_values": { + "VedAstro": None, + "PyJHora_JHora": None, + "jyotishganit": None, + }, + "status": "blocked", + "reason": "raw_values_not_normalized_across_all_three_engines", + }, + { + "section": "Panchanga", + "field": "raw_capture", + "local_value": None, + "oracle_values": { + "VedAstro": None, + "PyJHora_JHora": "dasha_only_artifact", + "jyotishganit": "captured" if jyotishganit_path else None, + }, + "status": "not_comparable", + "reason": "three_engine_scope_does_not_share_this_normalized_field", + }, + ] + report = { + "case_id": PUBLIC_CASE["case_id"], + "birth_data_policy": "public_case_only", + "status": "partial" if pyjhora_available and jyotishganit_path else "blocked", + "tested": False, + "blocked_reason": "official_vedastro_raw_missing_or_unverified", + "engines": { + "VedAstro": vedastro, + "PyJHora_JHora": { + "status": "raw_imported" if pyjhora_available else "blocked", + "raw_output_path": str(PYJHORA_ARTIFACT) if pyjhora_available else "", + "settings": {"ayanamsa": "LAHIRI", "node_mode": "PyJHora default"}, + }, + "jyotishganit": { + "status": "raw_captured" if jyotishganit_path else "blocked", + "raw_output_path": jyotishganit_path, + "error": jyotishganit_raw.get("error") if isinstance(jyotishganit_raw, dict) else None, + }, + }, + "local": { + "result_hash": local["result_hash"], + "calculation_contract": local["calculation_contract"], + }, + "comparison_rows": rows, + "runtime_boundary": ( + "This packet has real public raw artifacts but remains unverified until a VedAstro " + "official raw response and normalized three-engine field comparison are imported." + ), + } + _write_json(output_dir / "three_engine_parity_replay.json", report) + return report + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output-dir", default="scratch/local/three_engine_parity") + parser.add_argument("--allow-vedastro-network", action="store_true") + args = parser.parse_args() + report = build_public_case_replay( + output_dir=ROOT / args.output_dir, + allow_vedastro_network=args.allow_vedastro_network, + ) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/timezone_utils.py b/scripts/timezone_utils.py index 7918b001..a4febdb1 100644 --- a/scripts/timezone_utils.py +++ b/scripts/timezone_utils.py @@ -1,17 +1,5 @@ from datetime import datetime -import logging +def infer_timezone(lat: float, lon: float, dt: datetime) -> float: + from domain_calculation_service import infer_timezone_offset -def infer_timezone(lat: float, lon: float, dt: datetime, default: float = 8.0) -> float: - try: - from timezonefinder import TimezoneFinder - import pytz - tf = TimezoneFinder() - tz_name = tf.timezone_at(lng=lon, lat=lat) - if tz_name: - offset_seconds = pytz.timezone(tz_name).localize(dt).utcoffset().total_seconds() - offset = float(offset_seconds / 3600.0) - logging.info(f"[Timezone Auth] Detected {tz_name} offset {offset} for {dt}") - return offset - except Exception as e: - logging.warning(f"Timezone inference failed: {e}") - return float(default) + return infer_timezone_offset(lat=lat, lon=lon, local_datetime=dt) diff --git a/tests/conftest.py b/tests/conftest.py index aa374ed5..21b15bca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,16 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SCRIPTS = str(ROOT / "scripts") WORKBUDDY_SKILL_SCRIPTS = ".workbuddy/skills/jyotish-vedic-astrology/scripts" +SLOW_API_SECURITY_PREFIXES = ( + "test_vedastro_", + "test_high_rigor_", + "test_professional_reading", + "test_api_prompt_pack", + "test_consultation_workflow", + "test_thematic_report", + "test_capability_audit", + "test_technique_catalog", +) def ensure_project_scripts_first() -> None: @@ -28,4 +38,10 @@ def pytest_runtest_setup() -> None: ensure_project_scripts_first() +def pytest_collection_modifyitems(items) -> None: + for item in items: + if item.fspath.basename == "test_api_server_security.py" and item.name.startswith(SLOW_API_SECURITY_PREFIXES): + item.add_marker("slow") + + ensure_project_scripts_first() diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index f0325d00..a58e2205 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -134,7 +134,10 @@ class _HighRigorJobCaptureHandler(JyotishAPIHandler): class _PostCaptureHandler(JyotishAPIHandler): def __init__(self, path: str, payload: dict) -> None: raw = json.dumps(payload).encode('utf-8') - self.headers = _FakeHeaders({'Content-Length': str(len(raw))}) + self.headers = _FakeHeaders({ + 'Content-Length': str(len(raw)), + 'Content-Type': 'application/json', + }) self.server = _FakeServer() self.path = path self.rfile = BytesIO(raw) @@ -3377,7 +3380,7 @@ def test_chart_async_submit_returns_job_id(monkeypatch: pytest.MonkeyPatch) -> N def test_high_rigor_job_poll_endpoint_returns_cached_job_payload(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(jyotish_api_server, '_load_high_rigor_job_record', lambda job_id: { + monkeypatch.setattr(jyotish_api_server, '_load_high_rigor_job_record', lambda job_id, **_kwargs: { 'success': True, 'endpoint': 'high_rigor_workflow_async', 'mode': 'async_result', @@ -3397,7 +3400,7 @@ def test_high_rigor_job_poll_endpoint_returns_cached_job_payload(monkeypatch: py def test_chart_job_poll_endpoint_returns_cached_job_payload(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(jyotish_api_server, '_load_async_job_record', lambda scope, job_id: { + monkeypatch.setattr(jyotish_api_server, '_load_async_job_record', lambda scope, job_id, **_kwargs: { 'success': True, 'endpoint': 'chart_async', 'mode': 'async_result', diff --git a/tests/test_calculation_p0_regressions.py b/tests/test_calculation_p0_regressions.py new file mode 100644 index 00000000..05338e08 --- /dev/null +++ b/tests/test_calculation_p0_regressions.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import domain_calculation_service as calculation_service # noqa: E402 +import jyotish_api_server # noqa: E402 +from jyotish_api_server import JyotishAPIHandler # noqa: E402 +from jyotish_engine import _compute_chart_from_args # noqa: E402 + +BIRTH = { + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "second": 0, + "lat": 28.6139, + "lon": 77.2090, + "tz": 5.5, + "ayanamsa": "lahiri", +} + + +def test_true_node_changes_effective_rahu_and_contract() -> None: + mean = calculation_service.compute_chart({**BIRTH, "node_mode": "mean"}) + true = calculation_service.compute_chart({**BIRTH, "node_mode": "true"}) + + assert mean["planets"]["Rahu"]["lon"] != pytest.approx( + true["planets"]["Rahu"]["lon"], abs=1e-8 + ) + assert mean["calculation_contract"]["effective"]["node_mode"] == "mean" + assert true["calculation_contract"]["effective"]["node_mode"] == "true" + assert mean["result_hash"] != true["result_hash"] + + +def test_vimshottari_uses_birth_balance_as_canonical_timeline() -> None: + birth_dt = datetime(1990, 1, 1, 12, 0) + result = calculation_service.compute_vimshottari_timeline( + birth_dt=birth_dt, + moon_lon=100.0, + current_date=birth_dt, + ) + + first = result["periods"][0] + assert first["lord"] == "Saturn" + assert first["start"] == "1980-07-02" + assert first["end"] == "1999-07-02" + assert result["birth_balance"]["remaining_years"] == pytest.approx(9.5) + assert result["calculation_contract"]["algorithm"] == "vimshottari_birth_balance" + + +def test_sade_sati_uses_real_saturn_transit_for_reference_date() -> None: + result = calculation_service.compute_sade_sati( + moon_degree=300.0, + asc_degree=330.0, + reference_date="2026-07-11", + tz=5.5, + ayanamsa="lahiri", + ) + oracle = calculation_service.compute_transit_longitude( + planet="Saturn", + reference_date="2026-07-11", + tz=5.5, + ayanamsa="lahiri", + ) + + assert result["transit_saturn_lon"] == pytest.approx(oracle["longitude"], abs=1e-8) + assert result["provenance"]["data_layer"] == "true_transit_positions" + assert result["provenance"]["reference_date"] == "2026-07-11" + + +def test_timezone_inference_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + calculation_service, + "_lookup_timezone_name", + lambda _lat, _lon: None, + ) + + with pytest.raises(calculation_service.TimezoneInferenceError, match="timezone inference"): + calculation_service.infer_timezone_offset( + lat=0.0, + lon=0.0, + local_datetime=datetime(1990, 1, 1, 12, 0), + ) + + +def test_chart_hash_matches_domain_cli_and_rest( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0") + monkeypatch.setattr( + jyotish_api_server, + "_attach_vedastro_main_entry_overview", + lambda result, _birth: result, + ) + expected = calculation_service.compute_chart({**BIRTH, "node_mode": "true"}) + cli, _asc_idx, _jd, _ayanamsa = _compute_chart_from_args( + SimpleNamespace(**BIRTH, node_mode="true") + ) + rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync( + {**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"} + ) + + assert cli["result_hash"] == expected["result_hash"] + assert rest["result_hash"] == expected["result_hash"] + assert rest["birth"]["node_mode"] == "true" + assert rest["calculation_contract"]["effective"]["node_mode"] == "true" diff --git a/tests/test_runtime_security_p0.py b/tests/test_runtime_security_p0.py new file mode 100644 index 00000000..07bb0b81 --- /dev/null +++ b/tests/test_runtime_security_p0.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import hashlib +import sys +import time +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import jyotish_api_server as api # noqa: E402 +import report_builder # noqa: E402 + + +class _Headers(dict): + def get(self, key, default=None): + return super().get(key, default) + + +class _Server: + allowed_origins = {"http://localhost:3456"} + server_address = ("127.0.0.1", 5200) + + +def _handler(headers: dict[str, str]): + handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler) + handler.headers = _Headers(headers) + handler.server = _Server() + return handler + + +def test_untrusted_origin_is_rejected_before_post_side_effects() -> None: + handler = _handler( + { + "Origin": "https://evil.example", + "Host": "127.0.0.1:5200", + "Content-Type": "application/json", + } + ) + with pytest.raises(api.Forbidden, match="Origin"): + handler._enforce_request_security(require_json=True) + + +def test_post_requires_json_content_type() -> None: + handler = _handler( + { + "Origin": "http://localhost:3456", + "Host": "127.0.0.1:5200", + "Content-Type": "text/plain", + } + ) + with pytest.raises(api.UnsupportedMediaType): + handler._enforce_request_security(require_json=True) + + +@pytest.mark.parametrize( + "url", + [ + "https://example.com/image.png", + "http://127.0.0.1:8080/private", + "file:///etc/passwd", + "ftp://example.com/file", + ], +) +def test_report_renderer_blocks_external_and_local_resources(url: str) -> None: + assert report_builder.is_allowed_report_resource_url( + url, + report_url="file:///tmp/report.html", + ) is False + + +def test_report_renderer_allows_only_document_and_embedded_resources() -> None: + assert report_builder.is_allowed_report_resource_url( + "file:///tmp/report.html", + report_url="file:///tmp/report.html", + ) is True + assert report_builder.is_allowed_report_resource_url( + "data:image/png;base64,AA==", + report_url="file:///tmp/report.html", + ) is True + + +def test_async_job_identity_is_random_and_capability_protected( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(api, "_async_job_dir", lambda _scope: tmp_path) + first = api._new_async_job_identity("chart") + second = api._new_async_job_identity("chart") + assert first["job_id"] != second["job_id"] + assert len(first["job_id"].split("_", 1)[1]) >= 32 + assert first["access_token"] != second["access_token"] + + record = { + "job_id": first["job_id"], + "status": "queued", + "access_token_hash": hashlib.sha256(first["access_token"].encode()).hexdigest(), + "expires_at_unix": time.time() + 60, + } + api._write_async_job_record("chart", first["job_id"], record) + assert api._load_async_job_record( + "chart", first["job_id"], access_token=first["access_token"] + )["status"] == "queued" + with pytest.raises(api.JobAccessDenied): + api._load_async_job_record("chart", first["job_id"], access_token="wrong") + + +def test_expired_async_job_is_deleted( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(api, "_async_job_dir", lambda _scope: tmp_path) + identity = api._new_async_job_identity("chart") + api._write_async_job_record( + "chart", + identity["job_id"], + { + "job_id": identity["job_id"], + "access_token_hash": hashlib.sha256(identity["access_token"].encode()).hexdigest(), + "expires_at_unix": time.time() - 1, + }, + ) + assert api._load_async_job_record( + "chart", identity["job_id"], access_token=identity["access_token"] + ) is None + assert not (tmp_path / f"{identity['job_id']}.json").exists() + + +def test_authenticated_frontend_does_not_use_local_storage_api_base() -> None: + auth_source = (ROOT / "jyotish-app" / "auth.js").read_text(encoding="utf-8") + chat_source = (ROOT / "jyotish-app" / "ai-chat.js").read_text(encoding="utf-8") + assert "localStorage.getItem(API_BASE_KEY)" not in auth_source + assert "localStorage.getItem('jyotish_api_base')" not in chat_source + + +def test_frontend_async_poll_uses_ephemeral_job_capability() -> None: + bridge_source = (ROOT / "jyotish-app" / "api-bridge.js").read_text(encoding="utf-8") + assert "pollAsyncJob(data, { base })" in bridge_source + assert "Authorization: `Bearer ${job.access_token}`" in bridge_source + assert "sessionStorage.setItem('jyotish_job" not in bridge_source + + +def test_background_job_queue_rejects_when_capacity_is_full( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FullCapacity: + def acquire(self, blocking=False): + return False + + monkeypatch.setattr(api, "_ASYNC_JOB_CAPACITY", _FullCapacity()) + with pytest.raises(api.JobQueueFull): + api._submit_background_job(lambda: None) diff --git a/tests/test_three_engine_parity_runner.py b/tests/test_three_engine_parity_runner.py new file mode 100644 index 00000000..3887cbc8 --- /dev/null +++ b/tests/test_three_engine_parity_runner.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +from three_engine_parity_runner import build_public_case_replay # noqa: E402 + + +def test_public_same_chart_replay_never_promotes_missing_vedastro_raw(tmp_path: Path) -> None: + report = build_public_case_replay(output_dir=tmp_path, allow_vedastro_network=False) + + assert report["case_id"] == "steve_jobs_public_1955_lahiri" + assert report["birth_data_policy"] == "public_case_only" + assert report["engines"]["PyJHora_JHora"]["status"] == "raw_imported" + assert report["engines"]["jyotishganit"]["status"] == "raw_captured" + assert report["engines"]["VedAstro"]["status"] == "blocked" + assert report["status"] in {"partial", "blocked"} + assert report["tested"] is False + assert report["comparison_rows"] + assert all(row["status"] in {"blocked", "not_comparable"} for row in report["comparison_rows"])