From 12c0f4777de5afbe8fd7ecab397f967f7bdb4bb7 Mon Sep 17 00:00:00 2001 From: leefer Date: Wed, 29 Jul 2026 17:07:39 +0800 Subject: [PATCH] chore: register pages features APIs and data contracts --- config/README.md | 22 ++ config/api.config.json | 524 +++++++++++++++++++++++++ config/data-fields.config.json | 31 ++ config/features.config.json | 26 ++ config/pages.config.json | 21 + docs/governance/stage-04-registries.md | 48 +++ tests/test_governance_registries.py | 87 ++++ tools/build_api_registry.py | 107 +++++ 8 files changed, 866 insertions(+) create mode 100644 config/README.md create mode 100644 config/api.config.json create mode 100644 config/data-fields.config.json create mode 100644 config/features.config.json create mode 100644 config/pages.config.json create mode 100644 docs/governance/stage-04-registries.md create mode 100644 tests/test_governance_registries.py create mode 100644 tools/build_api_registry.py diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..b20850c --- /dev/null +++ b/config/README.md @@ -0,0 +1,22 @@ +# Governance Registries + +These registries describe the approved product surface during architecture migration. + +- `pages.config.json`: primary page identity, navigation group, access expectation, scrolling, + and mobile composition policy. +- `features.config.json`: feature ownership, backend access class, data scope, and availability. +- `api.config.json`: transitional inventory of current routes, generated from `server.py` and + assigned to a feature owner. +- `data-fields.config.json`: canonical data products, provider eligibility, intended use, and + known blocked datasets. + +During Stage 04 these files are contract inputs, not runtime replacements. Backend access in +`api_access.py` remains authoritative until the HTTP governance phase switches it atomically. +Frontend visibility remains a presentation concern and never grants backend access. + +Regenerate the transitional API inventory after a route change: + +```shell +python tools/build_api_registry.py +python tools/build_api_registry.py --check +``` diff --git a/config/api.config.json b/config/api.config.json new file mode 100644 index 0000000..02fd77f --- /dev/null +++ b/config/api.config.json @@ -0,0 +1,524 @@ +{ + "schema_version": 1, + "generated_from": "server.py", + "routes": [ + { + "method": "DELETE", + "path": "/api/account/birth-profile", + "match": "exact", + "feature": "account", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/account/birth-profile", + "match": "exact", + "feature": "account", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/account/password", + "match": "exact", + "feature": "account", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/account/status", + "match": "exact", + "feature": "account", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/admin/membership", + "match": "exact", + "feature": "admin", + "access": "admin" + }, + { + "method": "POST", + "path": "/api/admin/refresh", + "match": "exact", + "feature": "admin", + "access": "admin" + }, + { + "method": "GET", + "path": "/api/admin/settings", + "match": "exact", + "feature": "admin", + "access": "admin" + }, + { + "method": "POST", + "path": "/api/admin/settings", + "match": "exact", + "feature": "admin", + "access": "admin" + }, + { + "method": "POST", + "path": "/api/admin/settings/test", + "match": "exact", + "feature": "admin", + "access": "admin" + }, + { + "method": "GET", + "path": "/api/alerts", + "match": "exact", + "feature": "alerts", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/alerts", + "match": "exact", + "feature": "alerts", + "access": "authenticated" + }, + { + "method": "DELETE", + "path": "/api/alerts/(\\d+)", + "match": "regex", + "feature": "alerts", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/alerts/(\\d+)/read", + "match": "regex", + "feature": "alerts", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/alerts/read-all", + "match": "exact", + "feature": "alerts", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/assistant/chat", + "match": "exact", + "feature": "review", + "access": "member" + }, + { + "method": "DELETE", + "path": "/api/assistant/messages", + "match": "exact", + "feature": "review", + "access": "member" + }, + { + "method": "GET", + "path": "/api/assistant/messages", + "match": "exact", + "feature": "review", + "access": "member" + }, + { + "method": "GET", + "path": "/api/auction", + "match": "exact", + "feature": "auction", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/auth/login", + "match": "exact", + "feature": "auth", + "access": "public" + }, + { + "method": "POST", + "path": "/api/auth/logout", + "match": "exact", + "feature": "auth", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/auth/me", + "match": "exact", + "feature": "auth", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/auth/register", + "match": "exact", + "feature": "auth", + "access": "public" + }, + { + "method": "POST", + "path": "/api/backfill", + "match": "exact", + "feature": "admin", + "access": "admin" + }, + { + "method": "GET", + "path": "/api/chart/intraday", + "match": "exact", + "feature": "charts", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/dashboard", + "match": "exact", + "feature": "market", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/dragon-tiger", + "match": "exact", + "feature": "dragon_tiger", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/dragon-tiger/profiles", + "match": "exact", + "feature": "dragon_tiger", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/health", + "match": "exact", + "feature": "health", + "access": "public" + }, + { + "method": "POST", + "path": "/api/heaven/hexagram", + "match": "exact", + "feature": "heaven", + "access": "member" + }, + { + "method": "POST", + "path": "/api/heaven/interpret", + "match": "exact", + "feature": "heaven", + "access": "member" + }, + { + "method": "POST", + "path": "/api/heaven/personal", + "match": "exact", + "feature": "heaven", + "access": "member" + }, + { + "method": "GET", + "path": "/api/heaven/readings", + "match": "exact", + "feature": "heaven", + "access": "member" + }, + { + "method": "DELETE", + "path": "/api/heaven/readings/(\\d+)", + "match": "regex", + "feature": "heaven", + "access": "member" + }, + { + "method": "POST", + "path": "/api/heaven/sector-phases", + "match": "exact", + "feature": "heaven", + "access": "admin" + }, + { + "method": "DELETE", + "path": "/api/heaven/sector-phases/(.+)", + "match": "regex", + "feature": "heaven", + "access": "admin" + }, + { + "method": "GET", + "path": "/api/heaven/setup", + "match": "exact", + "feature": "heaven", + "access": "member" + }, + { + "method": "POST", + "path": "/api/mentors/chat", + "match": "exact", + "feature": "mentor", + "access": "member" + }, + { + "method": "DELETE", + "path": "/api/mentors/messages", + "match": "exact", + "feature": "mentor", + "access": "member" + }, + { + "method": "GET", + "path": "/api/mentors/messages", + "match": "exact", + "feature": "mentor", + "access": "member" + }, + { + "method": "POST", + "path": "/api/mentors/preferences", + "match": "exact", + "feature": "mentor", + "access": "member" + }, + { + "method": "GET", + "path": "/api/mentors/setup", + "match": "exact", + "feature": "mentor", + "access": "member" + }, + { + "method": "GET", + "path": "/api/notes", + "match": "exact", + "feature": "review", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/notes", + "match": "exact", + "feature": "review", + "access": "authenticated" + }, + { + "method": "DELETE", + "path": "/api/notes/(\\d+)", + "match": "regex", + "feature": "review", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/popularity", + "match": "exact", + "feature": "popularity", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/realtime-aggregate/health", + "match": "exact", + "feature": "market", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/reasons", + "match": "exact", + "feature": "admin", + "access": "admin" + }, + { + "method": "GET", + "path": "/api/rotation/history", + "match": "exact", + "feature": "rotation", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/rotation/members", + "match": "exact", + "feature": "rotation", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/screener/compile", + "match": "exact", + "feature": "screener", + "access": "member" + }, + { + "method": "POST", + "path": "/api/screener/run", + "match": "exact", + "feature": "screener", + "access": "member" + }, + { + "method": "GET", + "path": "/api/screener/setup", + "match": "exact", + "feature": "screener", + "access": "member" + }, + { + "method": "POST", + "path": "/api/screener/strategies", + "match": "exact", + "feature": "screener", + "access": "member" + }, + { + "method": "DELETE", + "path": "/api/screener/strategies/(\\d+)", + "match": "regex", + "feature": "screener", + "access": "member" + }, + { + "method": "POST", + "path": "/api/screener/sync", + "match": "exact", + "feature": "screener", + "access": "member" + }, + { + "method": "GET", + "path": "/api/screener/tracking", + "match": "exact", + "feature": "screener", + "access": "member" + }, + { + "method": "POST", + "path": "/api/screener/tracking", + "match": "exact", + "feature": "screener", + "access": "member" + }, + { + "method": "DELETE", + "path": "/api/screener/tracking/(\\d+)", + "match": "regex", + "feature": "screener", + "access": "member" + }, + { + "method": "POST", + "path": "/api/screener/tracking/refresh", + "match": "exact", + "feature": "screener", + "access": "member" + }, + { + "method": "GET", + "path": "/api/search", + "match": "exact", + "feature": "search", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/search/detail", + "match": "exact", + "feature": "search", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/seat-aliases", + "match": "exact", + "feature": "admin", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/seat-aliases", + "match": "exact", + "feature": "admin", + "access": "admin" + }, + { + "method": "GET", + "path": "/api/sentiment/history", + "match": "exact", + "feature": "sentiment", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/stock/(\\d{6})", + "match": "regex", + "feature": "market", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/stock/(\\d{6})/preview", + "match": "regex", + "feature": "market", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/themes", + "match": "exact", + "feature": "themes", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/themes/detail", + "match": "exact", + "feature": "themes", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/trades", + "match": "exact", + "feature": "review", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/trades", + "match": "exact", + "feature": "review", + "access": "authenticated" + }, + { + "method": "DELETE", + "path": "/api/trades/(\\d+)", + "match": "regex", + "feature": "review", + "access": "authenticated" + }, + { + "method": "GET", + "path": "/api/watchlist", + "match": "exact", + "feature": "review", + "access": "authenticated" + }, + { + "method": "POST", + "path": "/api/watchlist", + "match": "exact", + "feature": "review", + "access": "authenticated" + }, + { + "method": "DELETE", + "path": "/api/watchlist/(\\d{6})", + "match": "regex", + "feature": "review", + "access": "authenticated" + } + ] +} diff --git a/config/data-fields.config.json b/config/data-fields.config.json new file mode 100644 index 0000000..408f6f9 --- /dev/null +++ b/config/data-fields.config.json @@ -0,0 +1,31 @@ +{ + "schema_version": 1, + "providers": { + "tushare": {"class": "licensed", "calculation_allowed": true}, + "ifind": {"class": "licensed", "calculation_allowed": true}, + "eastmoney": {"class": "public_web", "calculation_allowed": false}, + "tencent": {"class": "public_web", "calculation_allowed": false}, + "local": {"class": "derived", "calculation_allowed": true}, + "unresolved": {"class": "missing", "calculation_allowed": false} + }, + "datasets": [ + {"id": "market.trade_calendar", "entity": "market", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["trade_date", "is_open", "previous_open_date"]}, + {"id": "market.stock_master", "entity": "stock", "frequency": "event", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["ts_code", "name", "industry", "market", "list_date"]}, + {"id": "market.stock_daily", "entity": "stock", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "adjustment": "current-unadjusted", "fields": ["open", "high", "low", "close", "pct_chg", "volume_shares", "amount_yuan"]}, + {"id": "market.daily_valuation", "entity": "stock", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["turnover_rate_pct", "volume_ratio", "total_mv_10k_yuan", "circ_mv_10k_yuan", "pe_ttm", "pb", "ps_ttm", "dv_ttm_pct"]}, + {"id": "market.fundamentals", "entity": "stock", "frequency": "quarterly", "primary": "tushare", "fallbacks": [], "usage": "calculation", "point_in_time": "announcement_date", "fields": ["roe_pct", "roa_pct", "roic_pct", "gross_margin_pct", "net_profit_yoy_pct", "revenue_yoy_pct", "operating_cashflow_quality"]}, + {"id": "market.moneyflow", "entity": "stock", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["small_net_yuan", "medium_net_yuan", "large_net_yuan", "extra_large_net_yuan", "total_net_yuan"]}, + {"id": "market.industry_sw", "entity": "industry", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["industry_code", "industry_name", "level", "members", "pct_chg", "turnover_rate_pct"]}, + {"id": "market.limit_events", "entity": "stock", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["limit_type", "first_time", "last_time", "open_times", "limit_reason", "consecutive_boards"]}, + {"id": "market.auction_close", "entity": "stock", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["price", "volume_shares", "amount_yuan", "pre_close", "turnover_rate_pct", "volume_ratio", "float_share"]}, + {"id": "market.auction_dynamic", "entity": "stock", "frequency": "snapshot", "primary": "ifind", "fallbacks": [], "usage": "calculation", "freshness_seconds": 10, "fields": ["quote_time", "price", "pct_chg", "volume_shares", "amount_yuan"]}, + {"id": "market.popularity", "entity": "stock", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["ths_rank", "dc_rank", "rank_change", "dual_source"]}, + {"id": "market.dragon_tiger", "entity": "stock", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["seat_name", "buy_yuan", "sell_yuan", "net_buy_yuan", "side", "reason"]}, + {"id": "chart.stock_daily", "entity": "stock", "frequency": "daily", "primary": "ifind", "fallbacks": [], "usage": "display", "adjustment": "forward1", "fields": ["open", "high", "low", "close", "volume_shares", "amount_yuan"]}, + {"id": "chart.intraday", "entity": "stock_or_index_or_board", "frequency": "minute", "primary": "ifind", "fallbacks": ["eastmoney"], "usage": "display", "fields": ["quote_time", "open", "high", "low", "close", "avg_price", "volume_shares", "amount_yuan"]}, + {"id": "observation.realtime_indices", "entity": "index", "frequency": "snapshot", "primary": "eastmoney", "fallbacks": ["tencent"], "usage": "display", "fields": ["quote_time", "price", "pct_chg", "amount_yuan"]}, + {"id": "derived.sentiment", "entity": "market", "frequency": "daily", "primary": "local", "fallbacks": [], "usage": "calculation", "fields": ["temperature", "stage", "direction", "confidence", "component_scores"]}, + {"id": "research.consensus", "entity": "stock", "frequency": "event", "primary": "unresolved", "fallbacks": [], "usage": "blocked", "fields": ["consensus_profit", "forecast_revision", "rating_change", "target_price", "report_count"]}, + {"id": "market.level2", "entity": "stock", "frequency": "tick", "primary": "unresolved", "fallbacks": [], "usage": "blocked", "fields": ["order_queue", "unmatched_orders", "tick_trades", "tick_orders", "open_board_depth"]} + ] +} diff --git a/config/features.config.json b/config/features.config.json new file mode 100644 index 0000000..521c80d --- /dev/null +++ b/config/features.config.json @@ -0,0 +1,26 @@ +{ + "schema_version": 1, + "roles": ["public", "authenticated", "member", "admin"], + "features": [ + {"id": "health", "title": "健康检查", "access": "public", "data_scope": "system", "enabled": true}, + {"id": "auth", "title": "账户认证", "access": "public", "data_scope": "user", "enabled": true}, + {"id": "account", "title": "账户设置", "access": "authenticated", "data_scope": "user", "enabled": true}, + {"id": "admin", "title": "系统管理", "access": "admin", "data_scope": "system", "enabled": true}, + {"id": "market", "title": "市场总览", "access": "authenticated", "data_scope": "shared", "enabled": true}, + {"id": "sentiment", "title": "情绪周期", "access": "authenticated", "data_scope": "shared", "enabled": true}, + {"id": "pools", "title": "市场股池", "access": "authenticated", "data_scope": "shared", "enabled": true}, + {"id": "ladder", "title": "市场天梯", "access": "authenticated", "data_scope": "shared", "enabled": true}, + {"id": "rotation", "title": "板块轮动", "access": "authenticated", "data_scope": "shared", "enabled": true}, + {"id": "auction", "title": "集合竞价", "access": "authenticated", "data_scope": "shared", "enabled": true}, + {"id": "themes", "title": "题材库", "access": "authenticated", "data_scope": "shared", "enabled": true}, + {"id": "popularity", "title": "人气热榜", "access": "authenticated", "data_scope": "shared", "enabled": true}, + {"id": "dragon_tiger", "title": "龙虎榜", "access": "authenticated", "data_scope": "shared", "enabled": true}, + {"id": "search", "title": "全局搜索", "access": "authenticated", "data_scope": "shared", "enabled": true}, + {"id": "charts", "title": "行情图表", "access": "authenticated", "data_scope": "shared", "enabled": true}, + {"id": "screener", "title": "智能选股", "access": "member", "data_scope": "mixed", "daily_llm_quota": true, "enabled": true}, + {"id": "mentor", "title": "问师", "access": "member", "data_scope": "user", "daily_llm_quota": true, "enabled": true}, + {"id": "heaven", "title": "问天", "access": "member", "data_scope": "user", "daily_llm_quota": true, "enabled": true}, + {"id": "review", "title": "我的复盘", "access": "authenticated", "data_scope": "user", "enabled": true}, + {"id": "alerts", "title": "提醒中心", "access": "authenticated", "data_scope": "user", "enabled": true} + ] +} diff --git a/config/pages.config.json b/config/pages.config.json new file mode 100644 index 0000000..8e5bc5b --- /dev/null +++ b/config/pages.config.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "pages": [ + {"id": "sentimentCycleView", "title": "情绪周期", "feature": "sentiment", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": true}, + {"id": "limitPool", "title": "涨停池", "feature": "pools", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}, + {"id": "brokenView", "title": "炸板池", "feature": "pools", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}, + {"id": "downView", "title": "跌停板", "feature": "pools", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}, + {"id": "yesterdayView", "title": "昨日涨停", "feature": "pools", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}, + {"id": "performanceView", "title": "涨停表现", "feature": "pools", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}, + {"id": "ladderView", "title": "市场天梯", "feature": "ladder", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}, + {"id": "rotationView", "title": "板块轮动", "feature": "rotation", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}, + {"id": "auctionView", "title": "集合竞价", "feature": "auction", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}, + {"id": "themeLibraryView", "title": "题材库", "feature": "themes", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}, + {"id": "popularityView", "title": "人气热榜", "feature": "popularity", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}, + {"id": "dragonView", "title": "龙虎榜", "feature": "dragon_tiger", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}, + {"id": "screenerView", "title": "智能选股", "feature": "screener", "group": "intelligence", "access": "member", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}, + {"id": "mentorView", "title": "问师", "feature": "mentor", "group": "intelligence", "access": "member", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}, + {"id": "heavenView", "title": "问天", "feature": "heaven", "group": "intelligence", "access": "member", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}, + {"id": "reviewWorkspaceView", "title": "我的复盘", "feature": "review", "group": "personal", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false} + ] +} diff --git a/docs/governance/stage-04-registries.md b/docs/governance/stage-04-registries.md new file mode 100644 index 0000000..db03c09 --- /dev/null +++ b/docs/governance/stage-04-registries.md @@ -0,0 +1,48 @@ +# Stage 04: Governance Registries + +Date: 2026-07-29 + +## Result + +Four versioned registries now describe the existing product before runtime decomposition: + +1. `config/pages.config.json` registers all 16 primary workspaces. +2. `config/features.config.json` registers 20 feature owners and their access/data scope. +3. `config/api.config.json` registers 74 method/path combinations, including dynamic paths. +4. `config/data-fields.config.json` registers 18 initial canonical data products and provider + eligibility. + +The API registry is generated from the current request handler so route drift fails tests. The +other registries are curated contracts and may change only through an explicit product or data +governance decision. + +## Access Decisions Preserved + +- Health, login, and registration are public. +- Market views require an authenticated account. +- Intelligent screening, Mentor, Wentian, and their history endpoints require membership. +- My Review remains visible to authenticated users; LLM assistant endpoints remain member + gated inside that workspace. +- System management and shared knowledge modification remain administrator-only. +- Frontend visibility does not grant API access. + +## Data Decisions Preserved + +- Tushare and licensed iFinD data may be calculation inputs when a dataset contract permits it. +- Eastmoney and Tencent public endpoints are display-only in the initial registry. +- Analyst consensus history and Level-2 microstructure remain blocked rather than silently + approximated. +- Chart fallbacks remain separate from calculation datasets. +- The current unadjusted deterministic daily-bar behavior is recorded honestly; adjustment + normalization is deferred to the data-quality phase rather than changed here. + +## Transitional Rule + +These files do not yet replace `server.py`, `api_access.py`, or the current frontend navigation. +They are checked against those surfaces to prevent untracked drift. Later phases make each +registry authoritative in one atomic migration with compatibility tests. + +## Verification + +`tests/test_governance_registries.py` verifies uniqueness, page coverage, current API coverage, +explicit public routes, feature ownership, role validity, and provider eligibility. diff --git a/tests/test_governance_registries.py b/tests/test_governance_registries.py new file mode 100644 index 0000000..3c01fc7 --- /dev/null +++ b/tests/test_governance_registries.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +from tools.build_api_registry import build as build_api_registry +from tools.build_architecture_inventory import build as build_architecture_inventory + + +ROOT = Path(__file__).resolve().parents[1] +CONFIG = ROOT / "config" + + +def load(name: str) -> dict: + return json.loads((CONFIG / name).read_text(encoding="utf-8")) + + +class GovernanceRegistryTests(unittest.TestCase): + def setUp(self) -> None: + self.features = load("features.config.json") + self.pages = load("pages.config.json") + self.api = load("api.config.json") + self.data = load("data-fields.config.json") + + def test_features_are_unique_and_use_declared_roles(self) -> None: + roles = set(self.features["roles"]) + items = self.features["features"] + ids = [item["id"] for item in items] + self.assertEqual(len(ids), len(set(ids))) + self.assertTrue(all(item["access"] in roles for item in items)) + self.assertTrue(all(isinstance(item["enabled"], bool) for item in items)) + + def test_page_registry_matches_the_current_primary_navigation(self) -> None: + inventory_pages = build_architecture_inventory()["pages"] + registered = self.pages["pages"] + self.assertEqual( + [(item["id"], item["title"]) for item in registered], + [(item["id"], item["title"]) for item in inventory_pages], + ) + feature_ids = {item["id"] for item in self.features["features"]} + self.assertTrue(all(page["feature"] in feature_ids for page in registered)) + self.assertEqual(sum(bool(page.get("default")) for page in registered), 1) + + def test_api_registry_is_current_and_owned(self) -> None: + self.assertEqual(self.api, build_api_registry()) + feature_ids = {item["id"] for item in self.features["features"]} + roles = set(self.features["roles"]) + keys = [] + for route in self.api["routes"]: + keys.append((route["method"], route["path"], route["match"])) + self.assertIn(route["feature"], feature_ids) + self.assertIn(route["access"], roles) + self.assertEqual(len(keys), len(set(keys))) + + def test_public_routes_are_explicitly_limited(self) -> None: + public = { + (item["method"], item["path"]) + for item in self.api["routes"] + if item["access"] == "public" + } + self.assertEqual( + public, + { + ("GET", "/api/health"), + ("POST", "/api/auth/login"), + ("POST", "/api/auth/register"), + }, + ) + + def test_calculation_datasets_use_approved_providers(self) -> None: + providers = self.data["providers"] + ids = [] + for dataset in self.data["datasets"]: + ids.append(dataset["id"]) + self.assertIn(dataset["primary"], providers) + for fallback in dataset.get("fallbacks", []): + self.assertIn(fallback, providers) + if dataset["usage"] == "calculation": + self.assertTrue(providers[dataset["primary"]]["calculation_allowed"]) + if dataset["primary"] == "unresolved": + self.assertEqual(dataset["usage"], "blocked") + self.assertEqual(len(ids), len(set(ids))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build_api_registry.py b/tools/build_api_registry.py new file mode 100644 index 0000000..64704e9 --- /dev/null +++ b/tools/build_api_registry.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT = ROOT / "config" / "api.config.json" +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def _owner(path: str) -> str: + ordered = ( + ("/api/health", "health"), + ("/api/auth", "auth"), + ("/api/admin", "admin"), + ("/api/account", "account"), + ("/api/sentiment", "sentiment"), + ("/api/dashboard", "market"), + ("/api/realtime-aggregate", "market"), + ("/api/rotation", "rotation"), + ("/api/auction", "auction"), + ("/api/themes", "themes"), + ("/api/popularity", "popularity"), + ("/api/dragon-tiger", "dragon_tiger"), + ("/api/search", "search"), + ("/api/chart", "charts"), + ("/api/stock", "market"), + ("/api/screener", "screener"), + ("/api/mentors", "mentor"), + ("/api/heaven", "heaven"), + ("/api/alerts", "alerts"), + ("/api/trades", "review"), + ("/api/assistant", "review"), + ("/api/watchlist", "review"), + ("/api/notes", "review"), + ("/api/reasons", "admin"), + ("/api/seat-aliases", "admin"), + ("/api/backfill", "admin"), + ) + for prefix, owner in ordered: + if path.startswith(prefix): + return owner + raise ValueError(f"API owner is not registered: {path}") + + +def _role(method: str, path: str) -> str: + if path == "/api/health" or path in {"/api/auth/register", "/api/auth/login"}: + return "public" + from api_access import required_role + + sample = re.sub(r"\\d\{6\}", "000001", path) + sample = re.sub(r"\\d\+", "1", sample) + sample = sample.replace("(.+)", "sample").replace("(", "").replace(")", "") + return required_role(method, sample) + + +def build() -> dict: + text = (ROOT / "server.py").read_text(encoding="utf-8") + method_matches = list(re.finditer(r"^ def do_(GET|POST|DELETE)\(", text, re.MULTILINE)) + routes = [] + for index, match in enumerate(method_matches): + method = match.group(1) + end = method_matches[index + 1].start() if index + 1 < len(method_matches) else len(text) + block = text[match.start():end] + exact_paths = set(re.findall(r'parsed\.path\s*==\s*"(/api/[^"]+)"', block)) + patterns = set( + re.findall( + r're\.(?:fullmatch|match)\(\s*r?["\']([^"\']*?/api/[^"\']+)["\']\s*,\s*parsed\.path', + block, + ) + ) + for path in sorted(exact_paths): + routes.append( + {"method": method, "path": path, "match": "exact", "feature": _owner(path), "access": _role(method, path)} + ) + for path in sorted(patterns): + normalized = path.replace("^", "").replace("$", "") + routes.append( + {"method": method, "path": normalized, "match": "regex", "feature": _owner(normalized), "access": _role(method, normalized)} + ) + routes.sort(key=lambda item: (item["path"], item["method"], item["match"])) + return {"schema_version": 1, "generated_from": "server.py", "routes": routes} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build the transitional API ownership registry") + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + rendered = json.dumps(build(), ensure_ascii=False, indent=2) + "\n" + if args.check: + if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != rendered: + raise SystemExit("API registry is stale; run tools/build_api_registry.py") + print("API registry is current.") + return 0 + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text(rendered, encoding="utf-8") + print(OUTPUT.relative_to(ROOT).as_posix()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())