Skip to content

Commit 6c4475c

Browse files
committed
Guard strategy choices by account domain
1 parent 015cfec commit 6c4475c

10 files changed

Lines changed: 380 additions & 54 deletions

.github/workflows/validate.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ jobs:
2525
python3 scripts/sync_strategy_switch_page_asset.py
2626
git diff --exit-code -- web/strategy-switch-console/page_asset.js web/strategy-switch-console/strategy_profiles_asset.js
2727
jq empty web/strategy-switch-console/strategy-profiles.example.json
28+
node --experimental-default-type=module tests/strategy_switch_worker_validation.mjs
2829
sed -n '/<script>/,/<\/script>/p' web/strategy-switch-console/index.html | sed '1d;$d' | node --check --input-type=commonjs
2930
node --check --input-type=module < web/strategy-switch-console/page_asset.js
3031
node --check --input-type=module < web/strategy-switch-console/strategy_profiles_asset.js

docs/strategy_switch_admin_backend.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Goal: keep the open-source switch page public and read-only by default, while al
88
- Public access: unsigned visitors can view the page, but cannot dispatch the workflow.
99
- Allowed switch users/orgs: `ALLOWED_GITHUB_LOGINS`, `ALLOWED_GITHUB_ORGS`, KV `auth_config.allowed_logins`, KV `auth_config.allowed_orgs`, and all admins.
1010
- Admin users/orgs: `STRATEGY_SWITCH_ADMIN_LOGINS`, `STRATEGY_SWITCH_ADMIN_ORGS`, KV `auth_config.admin_logins`, and KV `auth_config.admin_orgs`.
11-
- Account dropdowns: KV `account_options` first, falling back to `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`.
11+
- Account dropdowns and account strategy domains: KV `account_options` first, falling back to `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`.
1212
- Audit log: each admin save appends to KV `audit_log`, capped at 50 entries.
1313

1414
## Cloudflare KV
@@ -42,6 +42,7 @@ Without the KV binding, `/admin` is read-only and the Worker falls back to secre
4242
## Security Boundary
4343

4444
- The admin backend stores GitHub logins, GitHub organization names, and account routing metadata only.
45+
- Account config may include `supported_domains`, such as `us_equity` or `hk_equity`, so unsupported strategies are filtered in the UI and rejected by the Worker.
4546
- OAuth requests the `read:org` scope to verify membership in configured admin or allowlist organizations.
4647
- Broker passwords, tokens, API keys, and cloud credentials stay out of this config.
4748
- Admin writes use POST and same-origin checks.

docs/strategy_switch_admin_backend.zh-CN.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
- 公开访问:未登录用户只能看到只读切换页,不能触发 workflow。
99
- 可切换用户/组织:来自 `ALLOWED_GITHUB_LOGINS``ALLOWED_GITHUB_ORGS`、KV `auth_config.allowed_logins`、KV `auth_config.allowed_orgs` 和管理员配置。
1010
- 管理员用户/组织:来自 `STRATEGY_SWITCH_ADMIN_LOGINS``STRATEGY_SWITCH_ADMIN_ORGS`、KV `auth_config.admin_logins` 和 KV `auth_config.admin_orgs`
11-
- 账号下拉:优先读取 KV `account_options`,没有 KV 配置时回退 `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`
11+
- 账号下拉和账号策略市场范围:优先读取 KV `account_options`,没有 KV 配置时回退 `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`
1212
- 审计:管理员保存配置后写入 KV `audit_log`,保留最近 50 条。
1313

1414
## Cloudflare KV
@@ -42,6 +42,7 @@ audit_log
4242
## 安全边界
4343

4444
- 后台只保存 GitHub login、GitHub 组织名和账号路由信息。
45+
- 账号配置可以包含 `supported_domains`,例如 `us_equity``hk_equity`,用于前端过滤不支持的策略,并由 Worker 后端再次拒绝非法组合。
4546
- OAuth 会请求 `read:org` scope,用于校验登录用户是否属于配置的管理员组织或 allowlist 组织。
4647
- 不保存 broker 密码、token、API key 或云密钥。
4748
- 后台写操作使用 POST,并校验 Same-Origin。
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import assert from "node:assert/strict";
2+
3+
import { __test } from "../web/strategy-switch-console/worker.js";
4+
5+
const strategyProfiles = __test.normalizeStrategyProfilesPayload(
6+
[
7+
{
8+
profile: "tqqq_growth_income",
9+
label: "TQQQ Growth Income",
10+
domain: "us_equity",
11+
runtime_enabled: true,
12+
},
13+
{
14+
profile: "hk_low_vol_dividend_quality_snapshot",
15+
label: "HK Low-Vol Dividend Quality Snapshot",
16+
domain: "hk_equity",
17+
runtime_enabled: true,
18+
},
19+
],
20+
"test_strategy_profiles",
21+
);
22+
23+
const accountOptions = __test.normalizeAccountOptionsPayload(
24+
{
25+
longbridge: [
26+
{
27+
key: "hk",
28+
label: "hk",
29+
target_name: "hk",
30+
account_selector: "HK",
31+
default_strategy_profile: "hk_low_vol_dividend_quality_snapshot",
32+
},
33+
{
34+
key: "sg",
35+
label: "sg",
36+
target_name: "sg",
37+
account_selector: "SG",
38+
default_strategy_profile: "tqqq_growth_income",
39+
},
40+
],
41+
ibkr: [
42+
{
43+
key: "u15998061",
44+
label: "u15998061",
45+
target_name: "u15998061",
46+
account_selector: "U15998061",
47+
deployment_selector: "live-u1599-tqqq",
48+
account_scope: "live-u1599-tqqq",
49+
service_name: "interactive-brokers-live-u1599-tqqq-service",
50+
},
51+
],
52+
schwab: [
53+
{
54+
key: "default",
55+
label: "default",
56+
target_name: "default",
57+
supported_domains: ["us_equity"],
58+
},
59+
],
60+
firstrade: [
61+
{
62+
key: "default",
63+
label: "default",
64+
target_name: "default",
65+
supported_domains: ["us_equity"],
66+
},
67+
],
68+
},
69+
"test_account_options",
70+
);
71+
72+
assert.deepEqual(accountOptions.longbridge[0].supported_domains, ["hk_equity"]);
73+
assert.deepEqual(accountOptions.longbridge[1].supported_domains, ["us_equity"]);
74+
assert.deepEqual(accountOptions.ibkr[0].supported_domains, ["us_equity"]);
75+
76+
const longbridgeHk = __test.assertConfiguredAccount(
77+
{
78+
platform: "longbridge",
79+
target_name: "hk",
80+
account_selector: "HK",
81+
strategy_profile: "hk_low_vol_dividend_quality_snapshot",
82+
},
83+
accountOptions,
84+
);
85+
__test.assertStrategyAllowedForAccount(
86+
{
87+
platform: "longbridge",
88+
strategy_profile: "hk_low_vol_dividend_quality_snapshot",
89+
},
90+
longbridgeHk,
91+
strategyProfiles,
92+
);
93+
94+
const ibkrAccount = __test.assertConfiguredAccount(
95+
{
96+
platform: "ibkr",
97+
target_name: "u15998061",
98+
account_selector: "U15998061",
99+
deployment_selector: "live-u1599-tqqq",
100+
account_scope: "live-u1599-tqqq",
101+
service_name: "interactive-brokers-live-u1599-tqqq-service",
102+
strategy_profile: "tqqq_growth_income",
103+
},
104+
accountOptions,
105+
);
106+
__test.assertStrategyAllowedForAccount(
107+
{
108+
platform: "ibkr",
109+
strategy_profile: "tqqq_growth_income",
110+
},
111+
ibkrAccount,
112+
strategyProfiles,
113+
);
114+
assert.throws(
115+
() => __test.assertStrategyAllowedForAccount(
116+
{
117+
platform: "ibkr",
118+
strategy_profile: "hk_low_vol_dividend_quality_snapshot",
119+
},
120+
ibkrAccount,
121+
strategyProfiles,
122+
),
123+
/not supported/,
124+
);

web/strategy-switch-console/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,12 @@ Each account item supports:
103103
"deployment_selector": "live-u1599-tqqq",
104104
"account_scope": "live-u1599-tqqq",
105105
"service_name": "interactive-brokers-live-u1599-tqqq-service",
106-
"default_strategy_profile": "tqqq_growth_income"
106+
"default_strategy_profile": "tqqq_growth_income",
107+
"supported_domains": ["us_equity"]
107108
}
108109
```
109110

110-
The Worker validates dispatch inputs against this config. Keep only routing metadata here. Do not store broker passwords, tokens, or API keys in this config.
111+
The Worker validates dispatch inputs against this config, including whether the selected strategy domain is supported by the selected account. Keep only routing metadata here. Do not store broker passwords, tokens, or API keys in this config.
111112

112113
`/api/strategy-profiles` returns the public live-enabled strategy catalog for the dropdown. It reads the KV `strategy_profiles` key first, then `STRATEGY_SWITCH_STRATEGY_PROFILES_JSON`, then `strategy-profiles.example.json`.
113114

@@ -121,12 +122,13 @@ When adding or renaming a strategy profile:
121122

122123
- Add the runtime-enabled profile id and display label to `strategy-profiles.example.json`.
123124
- Run `python3 scripts/sync_strategy_switch_page_asset.py` so `strategy_profiles_asset.js` is regenerated.
124-
- Set each affected account's `default_strategy_profile` in `account-options.example.json` and the deployed KV account config.
125+
- Set `domain` on each strategy profile. Current values are `us_equity` and `hk_equity`.
126+
- Set each affected account's `default_strategy_profile` and `supported_domains` in `account-options.example.json` and the deployed KV account config.
125127
- Update the deployed KV `strategy_profiles` key from `strategy-profiles.example.json`.
126128
- Make sure the platform repository's current `RUNTIME_TARGET_JSON.strategy_profile` or account-specific `CLOUD_RUN_SERVICE_TARGETS_JSON` uses the same id.
127129
- Use lower-case ids with letters, numbers, dot, underscore, dash, or equals only. Do not encode account names or secrets in profile ids.
128130

129-
The console can display a dynamically read unknown profile, but the profile should still be added to the catalog so the UI and docs stay aligned.
131+
The console only allows live-enabled profiles whose `domain` is included in the selected account's `supported_domains`. If a profile is dynamically read from GitHub Variables but is missing from the catalog, add it to the catalog before switching to it.
130132

131133
## GitHub OAuth App
132134

web/strategy-switch-console/README.zh-CN.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,12 @@ wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-
110110
"deployment_selector": "live-u1599-tqqq",
111111
"account_scope": "live-u1599-tqqq",
112112
"service_name": "interactive-brokers-live-u1599-tqqq-service",
113-
"default_strategy_profile": "tqqq_growth_income"
113+
"default_strategy_profile": "tqqq_growth_income",
114+
"supported_domains": ["us_equity"]
114115
}
115116
```
116117

117-
Worker 会校验 dispatch 参数必须匹配这里的某个账号项。只放路由信息,不放 broker 密码、token、API key。
118+
Worker 会校验 dispatch 参数必须匹配这里的某个账号项,也会校验所选策略的 `domain` 是否在该账号的 `supported_domains`。只放路由信息,不放 broker 密码、token、API key。
118119

119120
`/api/strategy-profiles` 会返回公开的 live-enabled 策略目录,用于生成策略下拉框。读取优先级是 KV `strategy_profiles``STRATEGY_SWITCH_STRATEGY_PROFILES_JSON``strategy-profiles.example.json`
120121

@@ -128,12 +129,13 @@ Worker 会校验 dispatch 参数必须匹配这里的某个账号项。只放路
128129

129130
-`strategy-profiles.example.json` 增加 runtime-enabled profile id 和显示名称。
130131
- 运行 `python3 scripts/sync_strategy_switch_page_asset.py` 重新生成 `strategy_profiles_asset.js`
131-
-`account-options.example.json` 和已部署的 KV 账号配置里更新对应账号的 `default_strategy_profile`
132+
- 给每个策略 profile 设置 `domain`。当前支持 `us_equity``hk_equity`
133+
-`account-options.example.json` 和已部署的 KV 账号配置里更新对应账号的 `default_strategy_profile``supported_domains`
132134
-`strategy-profiles.example.json` 更新已部署 KV 的 `strategy_profiles` key。
133135
- 确认平台仓库当前的 `RUNTIME_TARGET_JSON.strategy_profile` 或账号级 `CLOUD_RUN_SERVICE_TARGETS_JSON` 使用同一个 id。
134136
- profile id 只使用小写字母、数字、点、下划线、短横线或等号。不要把账号名、密码、token、密钥信息写进 profile id。
135137

136-
切换页可以临时显示动态读取到但未登记的 profile,但后续仍应补进策略目录,保持 UI 和文档一致
138+
切换页只允许选择 runtime-enabled 且 `domain` 属于当前账号 `supported_domains` 的策略。如果从 GitHub Variables 动态读到了未登记 profile,先补进策略目录再切换
137139

138140
## GitHub OAuth App
139141

web/strategy-switch-console/account-options.example.json

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,24 @@
55
"label": "hk",
66
"target_name": "hk",
77
"account_selector": "HK",
8-
"default_strategy_profile": "hk_low_vol_dividend_quality_snapshot"
8+
"default_strategy_profile": "hk_low_vol_dividend_quality_snapshot",
9+
"supported_domains": ["hk_equity"]
910
},
1011
{
1112
"key": "sg",
1213
"label": "sg",
1314
"target_name": "sg",
1415
"account_selector": "SG",
15-
"default_strategy_profile": "tqqq_growth_income"
16+
"default_strategy_profile": "tqqq_growth_income",
17+
"supported_domains": ["us_equity"]
1618
},
1719
{
1820
"key": "paper",
1921
"label": "paper",
2022
"target_name": "paper",
2123
"account_selector": "PAPER",
22-
"default_strategy_profile": "mega_cap_leader_rotation_top50_balanced"
24+
"default_strategy_profile": "mega_cap_leader_rotation_top50_balanced",
25+
"supported_domains": ["us_equity"]
2326
}
2427
],
2528
"ibkr": [
@@ -31,7 +34,8 @@
3134
"deployment_selector": "live-u1599-tqqq",
3235
"account_scope": "live-u1599-tqqq",
3336
"service_name": "interactive-brokers-live-u1599-tqqq-service",
34-
"default_strategy_profile": "tqqq_growth_income"
37+
"default_strategy_profile": "tqqq_growth_income",
38+
"supported_domains": ["us_equity"]
3539
},
3640
{
3741
"key": "u16608560",
@@ -41,7 +45,8 @@
4145
"deployment_selector": "live-u1660-soxl",
4246
"account_scope": "live-u1660-soxl",
4347
"service_name": "interactive-brokers-live-u1660-soxl-service",
44-
"default_strategy_profile": "soxl_soxx_trend_income"
48+
"default_strategy_profile": "soxl_soxx_trend_income",
49+
"supported_domains": ["us_equity"]
4550
},
4651
{
4752
"key": "u18336562",
@@ -51,23 +56,26 @@
5156
"deployment_selector": "live-u1833-smart-dca",
5257
"account_scope": "live-u1833-smart-dca",
5358
"service_name": "interactive-brokers-live-u1833-smart-dca-service",
54-
"default_strategy_profile": "nasdaq_sp500_smart_dca"
59+
"default_strategy_profile": "nasdaq_sp500_smart_dca",
60+
"supported_domains": ["us_equity"]
5561
}
5662
],
5763
"schwab": [
5864
{
5965
"key": "default",
6066
"label": "default",
6167
"target_name": "default",
62-
"default_strategy_profile": "soxl_soxx_trend_income"
68+
"default_strategy_profile": "soxl_soxx_trend_income",
69+
"supported_domains": ["us_equity"]
6370
}
6471
],
6572
"firstrade": [
6673
{
6774
"key": "default",
6875
"label": "default",
6976
"target_name": "default",
70-
"default_strategy_profile": "mega_cap_leader_rotation_top50_balanced"
77+
"default_strategy_profile": "mega_cap_leader_rotation_top50_balanced",
78+
"supported_domains": ["us_equity"]
7179
}
7280
]
7381
}

0 commit comments

Comments
 (0)