Skip to content

Commit 30586b1

Browse files
authored
Merge pull request #340 from QuantStrategyLab/fix/console-csp-bootstrap
fix(console): keep public config within strict CSP
2 parents 6bc8c37 + f224bdf commit 30586b1

6 files changed

Lines changed: 85 additions & 199 deletions

File tree

docs/qsl_unified_control_console_architecture_v1.zh-CN.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323

2424
## 当前基础与主要缺口
2525

26-
现有 `web/strategy-switch-console` 已具备 GitHub OAuth、allowlist、管理员配置、KV 审计,以及受专用 token 保护的 `strategy_health_dashboard.v1` 只读健康快照。这是合适的起点
26+
现有 `web/strategy-switch-console` 已具备 GitHub OAuth、allowlist、管理员配置、KV 审计,以及受专用 token 保护的 `strategy_health_dashboard.v1` 只读健康快照。首页现在采用“人工决策台”:默认展示待处理决策,研究、证据和运行资料作为按需展开的下钻内容;历史策略切换则收敛到“策略设置”
2727

28-
但它今天仍以“策略切换”作为主要界面,并混合了历史的 `live` 配置元数据与手工 workflow dispatch。它尚未有跨仓候选目录、P0–P6 生命周期快照、统一决策队列或 P6 决策收据。因此不能把现有按钮当作新的全局执行授权
28+
它仍不能被误解为券商终端:跨仓目录、完整 P0–P6 生命周期、P6 决策收据和确定性执行网关仍须以独立快照/适配器逐步接线。因此,现有设置页的按钮不是新的全局执行授权
2929

3030
## 推荐的低风险演进
3131

@@ -56,6 +56,19 @@ Worker 以**独立于 dispatch token 的同步身份**接收来源快照、按 `
5656

5757
这遵循“仪表盘应直接回答问题、减少认知负担、异常优先”的运维原则;参考 [Grafana dashboard best practices](https://grafana.com/docs/grafana/latest/visualizations/dashboards/build-dashboards/best-practices/)[Google SRE monitoring guidance](https://sre.google/workbook/monitoring/)
5858

59+
### 成熟控制台对照后的产品准则
60+
61+
成熟的量化和运维控制台并不是把所有数据都放在首页,而是让每个异常都能被负责的人快速、可审计地处理。基于 QuantConnect、Grafana、Datadog、PagerDuty 与 Backstage 的公开设计,本站遵循以下约束:
62+
63+
1. **首页只放可行动事项。** 每项应逐步具备“策略 × 平台/账户目标、严重度、影响、证据新鲜度、当前安全状态、建议动作、责任人/运行手册”的最小字段。缺字段时显示“证据不足”,不能用绿灯代替。
64+
2. **告警按影响合并和分流。** 同一根因的运行告警应归为一个事项;高风险即时通知,需复核的事项批量通知,纯信息留在系统状态页。告警必须说明触发原因、如何判断与下一步,而不是只复制日志。[Grafana 告警实践](https://grafana.com/docs/grafana/latest/alerting/guides/best-practices/) 与其 [通知策略](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/create-notification-policy/) 都强调可行动性、分组与路由。
65+
3. **变更先证明,再执行。** 策略设置页只显示当前值、拟议差异、适用范围和就绪检查;执行证据、持仓、现金与运行状态保持只读。QuantConnect 的实盘模型同样将算法状态、持仓、现金、订单事件与停止/清仓操作分离,避免把“看见状态”误解为“可直接改券商”。[QuantConnect 实盘入门](https://www.quantconnect.com/docs/v2/local-platform/live-trading/getting-started)
66+
4. **事故有时间线,日常没有噪音。** 系统状态页仅突出 critical/review、最后可信样本与关联证据;后续补充的审计视图应记录状态、影响、负责人、检测与根因时间线,符合 [Datadog 事故管理](https://docs.datadoghq.com/incident_response/incident_management/) 的可追溯原则。
67+
5. **策略、插件与平台是同一套目录对象。** 它们应从代码/快照衍生身份、版本、责任、生命周期、运行手册和部署目标,而不是由网页手填。这个“目录先于大屏”的思路与 [Backstage Software Catalog](https://backstage.io/docs/features/software-catalog/) 一致。
68+
6. **不在控制台新增直连券商的紧急按钮。** 需要停止或降风险时,只创建绑定证据的受限意图,并由独立、幂等且可对账的执行网关消费;浏览器、AI、网页配置和 GitHub workflow 都不能绕开该边界。
69+
70+
当前三项主导航正好对应这套低频人工流程:**决策**(今天要判断什么)、**策略设置**(准备提交什么差异)、**系统状态**(为什么需要判断)。目录、通知策略与审计会在各自已有真实快照/权限接口后作为下钻加入,不能先用静态假数据占据首页。
71+
5972
### 2. 决策队列(仍不直接执行)
6073

6174
网页将“策略健康”扩展为“全局概览 + 决策队列”:
Lines changed: 26 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -1,204 +1,44 @@
11
#!/usr/bin/env python3
2-
"""Inject platform-config globals into index.html before </head>."""
2+
"""Keep the console's configuration bootstrap as a same-origin script.
3+
4+
The Worker serializes the non-secret configuration from ``config.js`` at
5+
``/bootstrap-config.js``. Keeping this loader external lets the page retain
6+
its strict ``script-src 'self'`` CSP without a hash or ``unsafe-inline``.
7+
"""
38

4-
import json
59
from pathlib import Path
610

11+
712
ROOT = Path(__file__).resolve().parents[2]
813
SOURCE = ROOT / "web" / "strategy-switch-console" / "index.html"
9-
CONFIG = ROOT / "platform-config.json"
14+
MARKER = "<!-- Generated by inject_platform_config.py -->"
15+
BOOTSTRAP = '<script src="/bootstrap-config.js"></script>'
1016

1117

1218
def main() -> int:
13-
config = json.loads(CONFIG.read_text(encoding="utf-8"))
14-
platforms = config["platforms"]
15-
strategies = config["strategies"]
16-
meta = config.get("meta", {})
17-
runtime_authority = meta.get("runtime_authority", {}) if isinstance(meta, dict) else {}
18-
19-
pc, dao, dca, inc_layer, opt_overlay = {}, {}, {}, {}, {}
20-
strategy_profiles = []
21-
domain_labels = {
22-
did: {"zh": ddata.get("label_zh", did), "en": ddata.get("label_en", did)}
23-
for did, ddata in config.get("domains", {}).items()
24-
}
25-
for pid, pdata in platforms.items():
26-
caps, depl = pdata["capabilities"], pdata["deployment"]
27-
pc[pid] = dict(
28-
dry_run_only=depl.get("dry_run_only", False),
29-
margin_policy=caps.get("margin_policy", False),
30-
reserved_cash=caps.get("reserved_cash", False),
31-
income_layer=caps.get("income_layer", False),
32-
option_overlay=caps.get("option_overlay", False),
33-
dca=caps.get("dca", False),
34-
execution_mode=depl.get("default_execution_mode", "live"),
35-
service_name=depl.get("service_name", ""),
36-
default_execution_mode=depl.get("default_execution_mode", "live"),
37-
)
38-
acct = pdata.get("default_account", {})
39-
entry = dict(
40-
key=acct.get("key", pid),
41-
label=acct.get("label", pdata.get("label", pid)),
42-
target_name=acct.get("target_name", acct.get("key", pid)),
43-
supported_domains=acct.get("supported_domains", pdata.get("supported_domains", [])),
44-
cash_currency=acct.get("cash_currency", "USD"),
45-
)
46-
for fld in (
47-
"service_name",
48-
"account_scope",
49-
"deployment_selector",
50-
"account_selector",
51-
"default_execution_mode",
52-
"min_reserved_cash_usd",
53-
"reserved_cash_ratio",
54-
"cash_only_execution_mode",
55-
"dca_mode",
56-
"dca_base_investment_usd",
57-
):
58-
if acct.get(fld):
59-
entry[fld] = acct[fld]
60-
if "service_name" not in entry:
61-
entry["service_name"] = depl.get("service_name", "")
62-
if "default_execution_mode" not in entry:
63-
entry["default_execution_mode"] = depl.get("default_execution_mode", "live")
64-
dao[pid] = [entry]
65-
for sid, sdata in strategies.items():
66-
feat = sdata.get("features", {})
67-
strategy_profiles.append(_strategy_profile_entry(sid, sdata))
68-
dd = sdata.get("dca_defaults")
69-
if dd:
70-
dca[sid] = dict(
71-
defaultMode=dd.get("default_mode", "fixed"),
72-
defaultBaseInvestmentUsd=str(dd.get("default_base_investment_usd", "1000")),
73-
)
74-
if feat.get("income_layer"):
75-
idl = sdata.get("income_layer_defaults", {})
76-
inc_layer[sid] = dict(
77-
startUsd=int(idl.get("start_usd", 0)),
78-
maxRatio=str(idl.get("max_ratio", "")),
79-
allocations=idl.get("allocations", {}),
80-
)
81-
if feat.get("option_overlay"):
82-
odl = sdata.get("option_overlay_defaults", {})
83-
families = []
84-
if odl.get("growth_enabled"):
85-
families.append(
86-
dict(
87-
family="growth",
88-
recipe=odl["growth_recipe"],
89-
startUsd=odl["growth_start_usd"],
90-
ratio=str(odl.get("nav_budget_ratio", "")),
91-
ratioKind="budget",
92-
)
93-
)
94-
if odl.get("income_enabled"):
95-
families.append(
96-
dict(
97-
family="income",
98-
recipe=odl["income_recipe"],
99-
startUsd=odl["income_start_usd"],
100-
ratio=str(odl.get("nav_risk_ratio", "")),
101-
ratioKind="risk",
102-
)
103-
)
104-
opt_overlay[sid] = dict(
105-
liveGate=odl.get("live_gate", ""), liveStatus=odl.get("live_status", ""), families=families
106-
)
107-
108-
block = "\n".join(
109-
[
110-
"<!-- Generated by inject_platform_config.py -->",
111-
'<script id="platform-config">',
112-
"window.__PLATFORM_CONFIG__ = " + json.dumps(pc, ensure_ascii=False) + ";",
113-
"window.__QSL_RUNTIME_AUTHORITY_STATUS__ = " + json.dumps(runtime_authority, ensure_ascii=False) + ";",
114-
"window.__DEFAULT_ACCOUNT_OPTIONS__ = " + json.dumps(dao, ensure_ascii=False) + ";",
115-
"window.__DOMAIN_LABELS__ = " + json.dumps(domain_labels, ensure_ascii=False) + ";",
116-
"window.__DEFAULT_STRATEGY_PROFILES__ = " + json.dumps(strategy_profiles, ensure_ascii=False) + ";",
117-
"window.__DCA_PROFILE_DEFAULTS__ = " + json.dumps(dca, ensure_ascii=False) + ";",
118-
"window.__INCOME_LAYER_DEFAULTS__ = " + json.dumps(inc_layer, ensure_ascii=False) + ";",
119-
"window.__OPTION_OVERLAY_DEFAULTS__ = " + json.dumps(opt_overlay, ensure_ascii=False) + ";",
120-
"</script>",
121-
]
122-
)
123-
12419
html = SOURCE.read_text(encoding="utf-8")
125-
marker = "<!-- Generated by inject_platform_config.py -->"
126-
existing = html.find('<script id="platform-config">')
127-
if existing >= 0:
128-
start = existing
129-
while True:
130-
prefix = html[:start].rstrip()
131-
marker_start = prefix.rfind(marker)
132-
if marker_start < 0:
133-
break
134-
if prefix[marker_start:].strip() != marker:
135-
break
136-
start = marker_start
137-
end = html.find("</script>", existing) + 9
138-
html = html[:start].rstrip() + "\n\n" + block + html[end:]
139-
else:
20+
replacement = f"{MARKER}\n{BOOTSTRAP}"
21+
legacy_script = html.find('<script id="platform-config">')
22+
23+
if legacy_script >= 0:
24+
start = legacy_script
25+
prefix = html[:start].rstrip()
26+
if prefix.endswith(MARKER):
27+
start = prefix.rfind(MARKER)
28+
end = html.find("</script>", legacy_script)
29+
if end < 0:
30+
raise RuntimeError("platform-config script is missing its closing tag")
31+
html = html[:start].rstrip() + "\n\n" + replacement + html[end + len("</script>"):]
32+
elif BOOTSTRAP not in html:
14033
head_end = html.find("</head>")
141-
html = html[:head_end] + "\n" + block + "\n" + html[head_end:]
34+
if head_end < 0:
35+
raise RuntimeError("index.html is missing </head>")
36+
html = html[:head_end] + "\n" + replacement + "\n" + html[head_end:]
14237

14338
SOURCE.write_text(html, encoding="utf-8")
144-
print("Injected platform-config into index.html")
39+
print("Configured same-origin bootstrap-config.js loader")
14540
return 0
14641

14742

148-
def _strategy_profile_entry(sid: str, sdata: dict) -> dict:
149-
feat = sdata.get("features", {})
150-
runtime_enabled = sdata.get("runtime_enabled", False)
151-
lifecycle_stage = str(
152-
sdata.get("lifecycle_stage") or ("runtime_enabled" if runtime_enabled else "research_active")
153-
).strip()
154-
can_switch_live = sdata.get(
155-
"can_switch_live",
156-
runtime_enabled and lifecycle_stage in {"live_enabled", "runtime_enabled"},
157-
)
158-
blocked_live_reason = sdata.get("blocked_live_reason")
159-
if blocked_live_reason is None and not can_switch_live:
160-
blocked_live_reason = lifecycle_stage or "not_runtime_enabled"
161-
continuity = sdata.get("live_continuity") if isinstance(sdata.get("live_continuity"), dict) else {}
162-
entry = {
163-
"profile": sid,
164-
"label": sdata.get("label", sid),
165-
"label_en": sdata.get("label_en", sid),
166-
"label_zh": sdata.get("label", sid),
167-
"domain": sdata.get("domain", ""),
168-
"runtime_enabled": runtime_enabled,
169-
"lifecycle_stage": lifecycle_stage,
170-
"can_switch_live": can_switch_live,
171-
"allowed_execution_modes": _normalize_allowed_execution_modes(sdata.get("allowed_execution_modes")),
172-
"blocked_live_reason": "" if blocked_live_reason is None else str(blocked_live_reason).strip(),
173-
"live_continuity": {
174-
"eligible": continuity.get("eligible") is True,
175-
"allowed_platforms": list(continuity.get("allowed_platforms") or []),
176-
},
177-
"income_layer_enabled": feat.get("income_layer", False),
178-
"option_overlay_enabled": feat.get("option_overlay", False),
179-
"combo_enabled": feat.get("combo", False),
180-
}
181-
if feat.get("combo"):
182-
entry["combo_mode"] = feat.get("combo_mode", "dynamic")
183-
return entry
184-
185-
186-
def _normalize_allowed_execution_modes(raw_modes: object) -> list[str]:
187-
if raw_modes is None:
188-
return ["paper", "dry_run"]
189-
if isinstance(raw_modes, str):
190-
modes = [raw_modes.strip()]
191-
elif isinstance(raw_modes, list):
192-
modes = [str(mode).strip() for mode in raw_modes]
193-
elif isinstance(raw_modes, tuple):
194-
modes = [str(mode).strip() for mode in raw_modes]
195-
elif isinstance(raw_modes, set):
196-
modes = [str(mode).strip() for mode in sorted(raw_modes)]
197-
else:
198-
modes = ["paper", "dry_run"]
199-
modes = [mode for mode in modes if mode]
200-
return modes if modes else ["paper", "dry_run"]
201-
202-
20343
if __name__ == "__main__":
20444
raise SystemExit(main())

tests/strategy_switch_worker_validation.mjs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ assert.ok(indexHtml.includes(".switch-surface.summary-hidden"));
9696
assert.ok(indexHtml.includes('summaryPanel.hidden = !showSummary'));
9797
assert.ok(indexHtml.includes('switchSurface.classList.toggle("summary-hidden", !showSummary)'));
9898
assert.equal(indexHtml.match(/Generated by inject_platform_config\.py/g)?.length, 1);
99+
assert.ok(indexHtml.includes('<script src="/bootstrap-config.js"></script>'));
100+
assert.equal(indexHtml.includes('<script id="platform-config">'), false);
99101
assert.equal(indexHtml.includes("publicSummary"), false);
100102
assert.ok(indexHtml.includes("function hasPrivateConfig()"));
101103
assert.ok(indexHtml.includes('el("quick-form").hidden = !showPrivateControls'));
@@ -247,10 +249,23 @@ assert.match(indexHtml, /body\.app-loading \.shell\s*\{\s*display: none;/);
247249
const servedPageResponse = await worker.fetch(new Request("https://switch.example/"), {});
248250
const servedHtml = await servedPageResponse.text();
249251
assert.equal(servedPageResponse.status, 200);
252+
assert.ok(servedHtml.includes('<script src="/bootstrap-config.js"></script>'));
253+
assert.equal(servedHtml.includes('<script id="platform-config">'), false);
250254
assert.equal(servedHtml.includes("ibkr-primary"), false);
251255
assert.equal(servedHtml.includes("longbridge-quant-sg-service"), false);
252256
assert.equal(servedHtml.includes('account_selector: "SG"'), false);
253257

258+
const bootstrapConfigResponse = await worker.fetch(new Request("https://switch.example/bootstrap-config.js"), {});
259+
const bootstrapConfigJs = await bootstrapConfigResponse.text();
260+
assert.equal(bootstrapConfigResponse.status, 200);
261+
assert.equal(bootstrapConfigResponse.headers.get("Content-Type"), "application/javascript; charset=utf-8");
262+
assert.equal(bootstrapConfigResponse.headers.get("X-Content-Type-Options"), "nosniff");
263+
assert.ok(bootstrapConfigJs.includes("window.__PLATFORM_CONFIG__"));
264+
assert.ok(bootstrapConfigJs.includes("window.__DEFAULT_STRATEGY_PROFILES__"));
265+
assert.equal(bootstrapConfigJs.includes("ibkr-primary"), false);
266+
assert.equal(bootstrapConfigJs.includes("longbridge-quant-sg-service"), false);
267+
assert.equal(bootstrapConfigJs.includes('account_selector: "SG"'), false);
268+
254269
const servedAppResponse = await worker.fetch(new Request("https://switch.example/app.js"), {});
255270
const servedAppJs = await servedAppResponse.text();
256271
assert.equal(servedAppResponse.status, 200);

0 commit comments

Comments
 (0)