Evidence Packet
+仅显示已完成任务的可审计计算状态、证据包和技法审计。不会展示内部提示词或原始出生输入。
+ +运行状态
Technique Audit
-
Machine Evidence Packet
-
Warnings
-
From e013fed79c51939ff7799fba03f646fe51eb95f1 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 23:40:01 +0800 Subject: [PATCH] Add API security and async job contracts --- jyotish-app/ai-chat.js | 1 - jyotish-app/api-bridge.js | 18 ++ jyotish-app/auth.js | 3 +- scripts/jyotish_api_server.py | 249 +++++++++++++++++++++++++-- scripts/report_builder.py | 8 + tests/test_api_async_job_contract.py | 105 +++++++++++ tests/test_runtime_security_p0.py | 156 +++++++++++++++++ web/evidence_packet.html | 26 +++ web/index.html | 9 + web/rectification.html | 15 ++ 10 files changed, 573 insertions(+), 17 deletions(-) create mode 100644 tests/test_api_async_job_contract.py create mode 100644 tests/test_runtime_security_p0.py create mode 100644 web/evidence_packet.html create mode 100644 web/index.html create mode 100644 web/rectification.html 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 e013df03..2df6e136 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('Async job timed out'); +} + async function fetchJson(path) { let lastError = null; let lastAttempt = null; @@ -511,6 +528,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/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index ea4e56d2..6fc58409 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -15,10 +15,13 @@ import json, sys, os, math import importlib.util import hashlib import re +import sqlite3 +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 HTTPServer, BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlparse @@ -57,6 +60,75 @@ _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) +_RATE_LIMIT_LOCK = threading.Lock() +_RATE_LIMIT_BUCKETS: dict[str, tuple[float, int]] = {} + + +def summarize_execution_status(result: dict | None) -> dict: + result = result if isinstance(result, dict) else {} + fallback = str(result.get('fallback_reason') or '') + official = 'official_blocked' if 'VedAstro official snapshot blocked' in fallback else result.get('official_evidence_status', 'unknown') + return { + 'official_evidence_status': official, + 'fallback_reason': result.get('fallback_reason'), + } + + +def build_evidence_packet_view(job_record: dict | None) -> dict: + """Public, token-protected job view. Excludes prompt internals and raw input.""" + job_record = job_record or {} + result = job_record.get('result') + result = result if isinstance(result, dict) else {} + return { + 'scope': 'evidence_packet_view', + 'job_id': job_record.get('job_id'), + 'status': job_record.get('status', 'unknown'), + 'execution_status': summarize_execution_status(result), + 'machine_evidence_packet': result.get('machine_evidence_packet') or {}, + 'technique_audit': result.get('technique_audit') or result.get('technique_audit_table') or [], + 'warnings': result.get('warnings') or [], + } + + +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 _rate_limit_per_minute() -> int: + raw = str(os.environ.get('JYOTISH_API_RATE_LIMIT_PER_MINUTE', '120')).strip() + try: + return max(int(raw), 0) + except ValueError: + return 120 + + +def enforce_rate_limit(client_id: str, *, now: float | None = None) -> None: + limit = _rate_limit_per_minute() + if limit == 0: + return + now = time.time() if now is None else now + with _RATE_LIMIT_LOCK: + window, count = _RATE_LIMIT_BUCKETS.get(client_id, (now, 0)) + if now - window >= 60: + window, count = now, 0 + if count >= limit: + raise RateLimited('Rate limit exceeded') + _RATE_LIMIT_BUCKETS[client_id] = (window, count + 1) def _western_evidence_packet_from_body( @@ -752,29 +824,140 @@ 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 _async_job_backend() -> str: + return "sqlite" if os.environ.get("JYOTISH_ASYNC_JOB_BACKEND", "file").strip().lower() == "sqlite" else "file" + + +def _sqlite_job_db_path() -> Path: + return Path(REPO_ROOT) / "scratch" / "local" / "async_jobs.sqlite3" + + +def _sqlite_job_connection() -> sqlite3.Connection: + path = _sqlite_job_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(path, timeout=10) + connection.execute( + "CREATE TABLE IF NOT EXISTS async_jobs (scope TEXT NOT NULL, job_id TEXT NOT NULL, expires_at REAL, payload TEXT NOT NULL, PRIMARY KEY (scope, job_id))" + ) + try: + os.chmod(path, 0o600) + except OSError: + pass + return connection + + +def prune_expired_async_jobs() -> dict: + """Best-effort startup cleanup for local job records; never reads payloads.""" + removed = 0 + scanned = 0 + if _async_job_backend() == "sqlite": + with _sqlite_job_connection() as connection: + scanned = connection.execute("SELECT COUNT(*) FROM async_jobs").fetchone()[0] + removed = connection.execute( + "DELETE FROM async_jobs WHERE expires_at IS NOT NULL AND expires_at <= ?", (time.time(),) + ).rowcount + return {'scope': 'async_job_cleanup', 'scanned': scanned, 'removed': removed} + for scope in (_HIGH_RIGOR_JOB_SCOPE, _API_CHART_CACHE_SCOPE): + directory = _async_job_dir(scope) + if not directory.is_dir(): + continue + for path in directory.glob('*.json'): + scanned += 1 + try: + record = json.loads(path.read_text(encoding='utf-8')) + expires_at = record.get('expires_at_unix') if isinstance(record, dict) else None + if isinstance(expires_at, (int, float)) and time.time() >= float(expires_at): + path.unlink() + removed += 1 + except (OSError, json.JSONDecodeError): + continue + return {'scope': 'async_job_cleanup', 'scanned': scanned, 'removed': removed} + + +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: - path = _async_job_path(scope, job_id) - if not path.exists(): - return None - try: - return json.loads(path.read_text(encoding='utf-8')) - except (OSError, json.JSONDecodeError): +def _load_async_job_record(scope: str, job_id: str, *, access_token: str = '') -> dict | None: + path = None + if _async_job_backend() == "sqlite": + with _sqlite_job_connection() as connection: + row = connection.execute( + "SELECT payload FROM async_jobs WHERE scope = ? AND job_id = ?", (scope, job_id) + ).fetchone() + if row is None: + return None + try: + record = json.loads(row[0]) + except json.JSONDecodeError: + return None + else: + path = _async_job_path(scope, job_id) + if not path.exists(): + return None + try: + 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): + if _async_job_backend() == "sqlite": + with _sqlite_job_connection() as connection: + connection.execute("DELETE FROM async_jobs WHERE scope = ? AND job_id = ?", (scope, job_id)) + elif path is not None: + 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', - ) + if _async_job_backend() == "sqlite": + with _sqlite_job_connection() as connection: + connection.execute( + "INSERT OR REPLACE INTO async_jobs (scope, job_id, expires_at, payload) VALUES (?, ?, ?, ?)", + (scope, job_id, payload.get("expires_at_unix"), json.dumps(payload, ensure_ascii=False, sort_keys=True)), + ) + return payload + 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 @@ -1098,6 +1281,26 @@ 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 RateLimited(RuntimeError): + """Client exceeded the local fixed-window request budget.""" + + class JyotishAPIHandler(BaseHTTPRequestHandler): server_version = 'JyotishAPI/6.9.14' @@ -1121,6 +1324,19 @@ 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 _vedastro_status(self): adapter = _load_local_module('vedastro_service_adapter') endpoint = os.environ.get('VEDASTRO_API_ENDPOINT', '').strip() @@ -1234,6 +1450,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) @@ -1378,6 +1595,10 @@ 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 e: + self._error_json(str(e), 403, 'ERR_FORBIDDEN') + except UnsupportedMediaType as e: + self._error_json(str(e), 415, 'ERR_UNSUPPORTED_MEDIA_TYPE') except Exception: import logging logging.exception("[api_server] request failed for %s", path) diff --git a/scripts/report_builder.py b/scripts/report_builder.py index bc0c22ea..bf3eb519 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 @@ -323,6 +324,13 @@ def build_cover(name, lagna, gender, status, pkg, desc, lang="cn"): """ +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 build_toc(sections, lang="cn"): """Generate table of contents HTML.""" toc_title = "目录" if lang == "cn" else "Table of Contents" diff --git a/tests/test_api_async_job_contract.py b/tests/test_api_async_job_contract.py new file mode 100644 index 00000000..994dcaf3 --- /dev/null +++ b/tests/test_api_async_job_contract.py @@ -0,0 +1,105 @@ +import json +import time +from pathlib import Path + +import pytest + +from scripts import jyotish_api_server as api + + +def test_evidence_packet_view_exposes_only_auditable_result_sections(): + packet = api.build_evidence_packet_view({ + "job_id": "job_1", + "status": "completed", + "result": { + "fallback_reason": "VedAstro official snapshot blocked: timeout", + "machine_evidence_packet": {"status": "draft", "metadata": {"capture_id": "x"}}, + "technique_audit": [{"technique": "D9", "status": "used"}], + "ai_prompt_pack": {"prompt_zh": "internal prompt"}, + }, + }) + + assert packet["job_id"] == "job_1" + assert packet["execution_status"]["official_evidence_status"] == "official_blocked" + assert packet["machine_evidence_packet"]["metadata"]["capture_id"] == "x" + assert "ai_prompt_pack" not in packet + + +def test_async_job_route_extracts_id_before_loading(monkeypatch, tmp_path): + record = {"job_id": "chart_abc", "status": "completed", "result": {}} + monkeypatch.setattr(api, "_load_async_job_record", lambda *args, **kwargs: record) + + handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler) + handler.path = "/api/chart/jobs/chart_abc" + handler.headers = {"Origin": ""} + handler._enforce_request_security = lambda: None + captured = {} + handler._json = lambda data, status=200: captured.update(data=data, status=status) + handler._error_json = lambda message, status=500, error_code="ERR_INTERNAL": captured.update(error=error_code, status=status) + handler._job_access_token = lambda: "token" + + handler.do_GET() + + assert captured["status"] == 200 + assert captured["data"]["job_id"] == "chart_abc" + + +def test_evidence_packet_page_is_present_and_does_not_embed_birth_data(): + page = Path(api.REPO_ROOT) / "web" / "evidence_packet.html" + source = page.read_text(encoding="utf-8") + + assert "Evidence Packet" in source + assert "access token" in source + assert "birth" not in source.lower() + + +def test_rectification_page_uses_choice_questionnaire_contract(): + page = Path(api.REPO_ROOT) / "web" / "rectification.html" + source = page.read_text(encoding="utf-8") + + assert "/api/rectification/questionnaire" in source + assert "/api/rectification/answers" in source + assert "候选簇排序" in source + + +def test_home_page_keeps_location_confirmation_local(): + page = Path(api.REPO_ROOT) / "web" / "index.html" + source = page.read_text(encoding="utf-8") + + assert "/api/location/resolve" in source + assert "第三方地理服务" in source + + +def test_startup_cleanup_removes_only_expired_job_records(monkeypatch, tmp_path): + expired = tmp_path / "expired.json" + active = tmp_path / "active.json" + expired.write_text(json.dumps({"expires_at_unix": time.time() - 1}), encoding="utf-8") + active.write_text(json.dumps({"expires_at_unix": time.time() + 60}), encoding="utf-8") + monkeypatch.setattr(api, "_async_job_dir", lambda scope: tmp_path) + + result = api.prune_expired_async_jobs() + + assert result["removed"] == 1 + assert not expired.exists() + assert active.exists() + + +def test_rate_limit_is_configurable_and_rejects_over_budget(monkeypatch): + api._RATE_LIMIT_BUCKETS.clear() + monkeypatch.setenv("JYOTISH_API_RATE_LIMIT_PER_MINUTE", "1") + + api.enforce_rate_limit("test-client", now=0) + with pytest.raises(api.RateLimited): + api.enforce_rate_limit("test-client", now=1) + + +def test_sqlite_async_job_backend_preserves_token_and_ttl(monkeypatch, tmp_path): + monkeypatch.setenv("JYOTISH_ASYNC_JOB_BACKEND", "sqlite") + monkeypatch.setattr(api, "_sqlite_job_db_path", lambda: tmp_path / "jobs.sqlite3") + record = {"job_id": "job_sqlite", "access_token_hash": api._access_token_hash("secret"), "expires_at_unix": time.time() + 60} + + api._write_async_job_record("test_scope", "job_sqlite", record) + + assert api._load_async_job_record("test_scope", "job_sqlite", access_token="secret")["job_id"] == "job_sqlite" + with pytest.raises(api.JobAccessDenied): + api._load_async_job_record("test_scope", "job_sqlite", access_token="wrong") 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/web/evidence_packet.html b/web/evidence_packet.html new file mode 100644 index 00000000..7b0a1104 --- /dev/null +++ b/web/evidence_packet.html @@ -0,0 +1,26 @@ + + + + +
仅显示已完成任务的可审计计算状态、证据包和技法审计。不会展示内部提示词或原始出生输入。
+ +-
-
-
先确认出生资料,再选择直接排盘或主动问询式生时校正。外部引擎状态将在证据包中明示。
+可使用本地城市库;未收录时请手填经纬度。此操作不调用第三方地理服务。
等待检查
先扫描候选时间,再回答选择题。结果只缩小候选簇,不宣称已经精确到分钟。
+