From 1553fe900a3648a6d66297a69de03ca8371bcff3 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:36:23 +0800 Subject: [PATCH 1/2] Add authenticated strategy switch console --- .github/workflows/manual-strategy-switch.yml | 358 ++++++ .github/workflows/validate.yml | 12 +- README.md | 42 +- README.zh-CN.md | 42 +- docs/.nojekyll | 1 + docs/github_pages_strategy_switch.md | 107 ++ docs/github_pages_strategy_switch.zh-CN.md | 107 ++ docs/index.html | 1061 +++++++++++++++++ ...trategy_switch_permission_control.zh-CN.md | 102 ++ docs/manual_strategy_switch_web.html | 12 + docs/strategy_switch_admin_backend.md | 55 + docs/strategy_switch_admin_backend.zh-CN.md | 54 + examples/targets/firstrade/live.example.json | 33 + scripts/build_runtime_switch.py | 413 +++++++ scripts/runtime_settings.py | 46 +- scripts/sync_strategy_switch_page_asset.py | 26 + tests/test_runtime_settings.py | 215 +++- web/strategy-switch-console/README.md | 129 ++ web/strategy-switch-console/README.zh-CN.md | 147 +++ .../account-options.example.json | 45 + web/strategy-switch-console/page_asset.js | 2 + web/strategy-switch-console/worker.js | 563 +++++++++ .../wrangler.toml.example | 18 + 23 files changed, 3577 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/manual-strategy-switch.yml create mode 100644 docs/.nojekyll create mode 100644 docs/github_pages_strategy_switch.md create mode 100644 docs/github_pages_strategy_switch.zh-CN.md create mode 100644 docs/index.html create mode 100644 docs/manual_strategy_switch_permission_control.zh-CN.md create mode 100644 docs/manual_strategy_switch_web.html create mode 100644 docs/strategy_switch_admin_backend.md create mode 100644 docs/strategy_switch_admin_backend.zh-CN.md create mode 100644 examples/targets/firstrade/live.example.json create mode 100644 scripts/build_runtime_switch.py create mode 100644 scripts/sync_strategy_switch_page_asset.py create mode 100644 web/strategy-switch-console/README.md create mode 100644 web/strategy-switch-console/README.zh-CN.md create mode 100644 web/strategy-switch-console/account-options.example.json create mode 100644 web/strategy-switch-console/page_asset.js create mode 100644 web/strategy-switch-console/worker.js create mode 100644 web/strategy-switch-console/wrangler.toml.example diff --git a/.github/workflows/manual-strategy-switch.yml b/.github/workflows/manual-strategy-switch.yml new file mode 100644 index 0000000..e29a606 --- /dev/null +++ b/.github/workflows/manual-strategy-switch.yml @@ -0,0 +1,358 @@ +name: Manual Strategy Switch + +on: + workflow_dispatch: + inputs: + platform: + description: "Target platform." + required: true + type: choice + options: + - longbridge + - ibkr + - schwab + - firstrade + target_name: + description: "Target name, e.g. sg, live, live-u1599-tqqq." + required: true + type: string + strategy_profile: + description: "Canonical strategy profile to switch to." + required: true + type: string + execution_mode: + description: "live writes live mode; paper sets dry_run_only=true and execution_mode=paper." + required: true + type: choice + default: live + options: + - live + - paper + variable_scope: + description: "Where GitHub variables are written. blank = platform default." + required: false + type: choice + default: default + options: + - default + - repository + - environment + github_environment: + description: "Environment name when variable_scope=environment. blank = platform default." + required: false + type: string + deployment_selector: + description: "Runtime deployment_selector. blank = derived from target_name." + required: false + type: string + account_selector: + description: "Comma-separated broker account selectors. blank = account_scope." + required: false + type: string + account_scope: + description: "Runtime account_scope/account group. blank = deployment_selector." + required: false + type: string + service_name: + description: "Cloud Run service name. blank = platform default." + required: false + type: string + plugin_mode: + description: "auto mounts known strategy plugin artifacts; custom uses custom_plugin_mounts_json." + required: true + type: choice + default: auto + options: + - auto + - none + - custom + custom_plugin_mounts_json: + description: "JSON list or {strategy_plugins:[...]} when plugin_mode=custom." + required: false + type: string + extra_variables_json: + description: "Optional JSON object of non-secret extra GitHub variables or target env fields." + required: false + type: string + reserved_cash_ratio: + description: "Optional platform reserved-cash ratio override." + required: false + type: string + min_reserved_cash_usd: + description: "Optional platform minimum reserved cash override." + required: false + type: string + income_threshold_usd: + description: "Optional TQQQ income threshold override." + required: false + type: string + qqqi_income_ratio: + description: "Optional TQQQ QQQI income ratio override." + required: false + type: string + service_targets_mode: + description: "auto patches IBKR CLOUD_RUN_SERVICE_TARGETS_JSON when it exists." + required: true + type: choice + default: auto + options: + - auto + - off + apply: + description: "Actually write GitHub variables. false = preview only." + required: true + type: boolean + default: false + trigger_platform_sync: + description: "After apply, dispatch the target platform sync-cloud-run-env workflow." + required: true + type: boolean + default: false + confirm_apply: + description: "Required for writes. Use APPLY for variable writes, APPLY_AND_SYNC when trigger_platform_sync=true." + required: false + type: string + platform_sync_workflow: + description: "Target platform workflow filename." + required: false + type: string + default: sync-cloud-run-env.yml + +concurrency: + group: runtime-strategy-switch-${{ inputs.platform }}-${{ inputs.target_name }} + cancel-in-progress: false + +jobs: + switch: + name: Build and apply runtime switch + runs-on: ubuntu-latest + environment: runtime-strategy-switch + permissions: + contents: read + env: + GH_TOKEN: ${{ secrets.RUNTIME_SETTINGS_GH_TOKEN }} + PLATFORM: ${{ inputs.platform }} + TARGET_NAME: ${{ inputs.target_name }} + STRATEGY_PROFILE: ${{ inputs.strategy_profile }} + EXECUTION_MODE: ${{ inputs.execution_mode }} + VARIABLE_SCOPE: ${{ inputs.variable_scope }} + GITHUB_ENVIRONMENT_NAME: ${{ inputs.github_environment }} + DEPLOYMENT_SELECTOR: ${{ inputs.deployment_selector }} + ACCOUNT_SELECTOR: ${{ inputs.account_selector }} + ACCOUNT_SCOPE: ${{ inputs.account_scope }} + SERVICE_NAME: ${{ inputs.service_name }} + PLUGIN_MODE: ${{ inputs.plugin_mode }} + CUSTOM_PLUGIN_MOUNTS_JSON: ${{ inputs.custom_plugin_mounts_json }} + EXTRA_VARIABLES_JSON: ${{ inputs.extra_variables_json }} + RESERVED_CASH_RATIO: ${{ inputs.reserved_cash_ratio }} + MIN_RESERVED_CASH_USD: ${{ inputs.min_reserved_cash_usd }} + INCOME_THRESHOLD_USD: ${{ inputs.income_threshold_usd }} + QQQI_INCOME_RATIO: ${{ inputs.qqqi_income_ratio }} + SERVICE_TARGETS_MODE: ${{ inputs.service_targets_mode }} + APPLY_SWITCH: ${{ inputs.apply }} + TRIGGER_PLATFORM_SYNC: ${{ inputs.trigger_platform_sync }} + CONFIRM_APPLY: ${{ inputs.confirm_apply }} + PLATFORM_SYNC_WORKFLOW: ${{ inputs.platform_sync_workflow }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Enforce write safety gates + run: | + set -euo pipefail + + if [ "${APPLY_SWITCH}" != "true" ] && [ "${TRIGGER_PLATFORM_SYNC}" = "true" ]; then + echo "trigger_platform_sync=true is invalid unless apply=true." >&2 + exit 2 + fi + + if [ "${APPLY_SWITCH}" = "true" ]; then + if [ -z "${GH_TOKEN:-}" ]; then + echo "RUNTIME_SETTINGS_GH_TOKEN is required for apply=true." >&2 + exit 2 + fi + if [ "${TRIGGER_PLATFORM_SYNC}" = "true" ]; then + if [ "${CONFIRM_APPLY:-}" != "APPLY_AND_SYNC" ]; then + echo "Set confirm_apply=APPLY_AND_SYNC when trigger_platform_sync=true." >&2 + exit 2 + fi + elif [ "${CONFIRM_APPLY:-}" != "APPLY" ]; then + echo "Set confirm_apply=APPLY when apply=true." >&2 + exit 2 + fi + fi + + if [ "${PLATFORM}" = "ibkr" ] \ + && [ "${SERVICE_TARGETS_MODE}" = "auto" ] \ + && [ -z "${GH_TOKEN:-}" ]; then + echo "RUNTIME_SETTINGS_GH_TOKEN is required for IBKR service-target preview because the workflow must read and patch CLOUD_RUN_SERVICE_TARGETS_JSON." >&2 + exit 2 + fi + + - name: Resolve platform repository + id: platform + run: | + set -euo pipefail + case "${PLATFORM}" in + longbridge) + repo="QuantStrategyLab/LongBridgePlatform" + ;; + ibkr) + repo="QuantStrategyLab/InteractiveBrokersPlatform" + ;; + schwab) + repo="QuantStrategyLab/CharlesSchwabPlatform" + ;; + firstrade) + repo="QuantStrategyLab/FirstradePlatform" + ;; + *) + echo "Unsupported platform: ${PLATFORM}" >&2 + exit 2 + ;; + esac + echo "repository=${repo}" >> "$GITHUB_OUTPUT" + + - name: Fetch existing service targets + if: env.SERVICE_TARGETS_MODE == 'auto' && env.PLATFORM == 'ibkr' + env: + TARGET_REPOSITORY: ${{ steps.platform.outputs.repository }} + run: | + set -euo pipefail + output_file="${RUNNER_TEMP}/existing-service-targets.json" + python - <<'PY' "${TARGET_REPOSITORY}" "${output_file}" + import json + import subprocess + import sys + + repo, output_path = sys.argv[1], sys.argv[2] + raw = subprocess.check_output( + ["gh", "variable", "list", "--repo", repo, "--json", "name,value"], + text=True, + ) + variables = json.loads(raw) + value = "" + for item in variables: + if item.get("name") == "CLOUD_RUN_SERVICE_TARGETS_JSON": + value = str(item.get("value") or "").strip() + break + if not value: + open(output_path, "w", encoding="utf-8").close() + raise SystemExit(0) + try: + payload = json.loads(value) + except json.JSONDecodeError: + payload = json.loads(value.replace("\\n", "\n")) + with open(output_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, separators=(",", ":")) + PY + echo "EXISTING_SERVICE_TARGETS_JSON_FILE=${output_file}" >> "$GITHUB_ENV" + + - name: Build switch target + run: | + set -euo pipefail + target_file="${RUNNER_TEMP}/runtime-switch-target.json" + args=( + --platform "${PLATFORM}" + --target-name "${TARGET_NAME}" + --strategy-profile "${STRATEGY_PROFILE}" + --execution-mode "${EXECUTION_MODE}" + --plugin-mode "${PLUGIN_MODE}" + --output "${target_file}" + ) + if [ "${VARIABLE_SCOPE}" != "default" ]; then + args+=(--variable-scope "${VARIABLE_SCOPE}") + fi + if [ -n "${GITHUB_ENVIRONMENT_NAME:-}" ]; then + args+=(--github-environment "${GITHUB_ENVIRONMENT_NAME}") + fi + if [ -n "${DEPLOYMENT_SELECTOR:-}" ]; then + args+=(--deployment-selector "${DEPLOYMENT_SELECTOR}") + fi + if [ -n "${ACCOUNT_SELECTOR:-}" ]; then + args+=(--account-selector "${ACCOUNT_SELECTOR}") + fi + if [ -n "${ACCOUNT_SCOPE:-}" ]; then + args+=(--account-scope "${ACCOUNT_SCOPE}") + fi + if [ -n "${SERVICE_NAME:-}" ]; then + args+=(--service-name "${SERVICE_NAME}") + fi + if [ -n "${CUSTOM_PLUGIN_MOUNTS_JSON:-}" ]; then + args+=(--custom-plugin-mounts-json "${CUSTOM_PLUGIN_MOUNTS_JSON}") + fi + if [ -n "${EXTRA_VARIABLES_JSON:-}" ]; then + args+=(--extra-variables-json "${EXTRA_VARIABLES_JSON}") + fi + if [ -n "${RESERVED_CASH_RATIO:-}" ]; then + args+=(--reserved-cash-ratio "${RESERVED_CASH_RATIO}") + fi + if [ -n "${MIN_RESERVED_CASH_USD:-}" ]; then + args+=(--min-reserved-cash-usd "${MIN_RESERVED_CASH_USD}") + fi + if [ -n "${INCOME_THRESHOLD_USD:-}" ]; then + args+=(--income-threshold-usd "${INCOME_THRESHOLD_USD}") + fi + if [ -n "${QQQI_INCOME_RATIO:-}" ]; then + args+=(--qqqi-income-ratio "${QQQI_INCOME_RATIO}") + fi + if [ -s "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then + args+=(--existing-service-targets-json-file "${EXISTING_SERVICE_TARGETS_JSON_FILE}") + fi + python3 scripts/build_runtime_switch.py "${args[@]}" + python3 scripts/runtime_settings.py validate "${target_file}" + echo "TARGET_FILE=${target_file}" >> "$GITHUB_ENV" + + - name: Preview assignments + run: | + set -euo pipefail + python3 scripts/runtime_settings.py render "${TARGET_FILE}" --format env + python3 scripts/runtime_settings.py render "${TARGET_FILE}" --format json > "${RUNNER_TEMP}/assignments.json" + python - <<'PY' "${TARGET_FILE}" "${RUNNER_TEMP}/assignments.json" >> "$GITHUB_STEP_SUMMARY" + import json + import sys + + target = json.load(open(sys.argv[1], encoding="utf-8")) + assignments = json.load(open(sys.argv[2], encoding="utf-8")) + print("## Runtime switch preview") + print() + print(f"- target_id: `{target['target_id']}`") + print(f"- repository: `{target['github']['repository']}`") + print(f"- variable_scope: `{target['github']['variable_scope']}`") + if target["github"].get("environment"): + print(f"- environment: `{target['github']['environment']}`") + print(f"- strategy_profile: `{target['runtime_target']['strategy_profile']}`") + print(f"- service_name: `{target['runtime_target']['service_name']}`") + print(f"- execution_mode: `{target['runtime_target']['execution_mode']}`") + print() + print("### Variables") + for assignment in assignments: + value = str(assignment["value"]) + preview = value if len(value) <= 220 else value[:220] + "..." + print(f"- `{assignment['name']}` = `{preview}`") + PY + + - name: Apply GitHub variable updates + if: env.APPLY_SWITCH == 'true' + run: python3 scripts/runtime_settings.py apply "${TARGET_FILE}" --yes + + - name: Dispatch platform sync workflow + if: env.APPLY_SWITCH == 'true' && env.TRIGGER_PLATFORM_SYNC == 'true' + env: + TARGET_REPOSITORY: ${{ steps.platform.outputs.repository }} + run: | + set -euo pipefail + workflow="${PLATFORM_SYNC_WORKFLOW:-sync-cloud-run-env.yml}" + case "${PLATFORM}" in + longbridge|ibkr) + gh workflow run "${workflow}" --repo "${TARGET_REPOSITORY}" --ref main -f target=configured + ;; + schwab|firstrade) + gh workflow run "${workflow}" --repo "${TARGET_REPOSITORY}" --ref main + ;; + *) + echo "No platform sync dispatch rule for ${PLATFORM}" >&2 + exit 2 + ;; + esac + echo "Dispatched ${workflow} in ${TARGET_REPOSITORY}." diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c88d844..e5df93d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -12,8 +12,18 @@ jobs: - uses: actions/setup-python@v6 with: python-version: "3.12" + - uses: actions/setup-node@v6 + with: + node-version: "22" - name: Validate runtime targets run: python3 scripts/runtime_settings.py validate - name: Run unit tests run: python3 -m unittest discover -s tests -v - + - name: Validate strategy switch web assets + run: | + set -euo pipefail + python3 scripts/sync_strategy_switch_page_asset.py + git diff --exit-code -- web/strategy-switch-console/page_asset.js + sed -n '/ + + diff --git a/docs/manual_strategy_switch_permission_control.zh-CN.md b/docs/manual_strategy_switch_permission_control.zh-CN.md new file mode 100644 index 0000000..92a609a --- /dev/null +++ b/docs/manual_strategy_switch_permission_control.zh-CN.md @@ -0,0 +1,102 @@ +# 手动策略切换权限控制方案 + +这是个人量化系统的简化权限方案。默认目标不是做团队审批,而是让你自己能像让 Codex 切换一样直接操作,同时保留必要的防误触和防泄密边界。 + +## 默认方案:个人单人模式 + +只需要这几条: + +1. 只有你自己的 GitHub 账号有这个仓库的 write/admin 权限。 +2. 在 GitHub secret 里配置 `RUNTIME_SETTINGS_GH_TOKEN`。 +3. token 只给目标平台仓库需要的 variables/workflow 权限,不给 `contents: write`。 +4. 第一次运行 workflow 用 `apply=false` 看 preview。 +5. 确认后再运行 `apply=true`,填写 `confirm_apply=APPLY` 或 `APPLY_AND_SYNC`。 +6. 不把 broker、email、cloud、API token 等密钥放进 `extra_variables_json`。 + +这个模式不要求 required reviewers。workflow 绑定了 `runtime-strategy-switch` Environment,但这个 Environment 可以不配置审批人;它主要用于隔离 secret 和保留 Actions 审计。 + +## 最简 GitHub 设置 + +在 `QuantRuntimeSettings` 仓库配置: + +- Environment:`runtime-strategy-switch` +- Secret:`RUNTIME_SETTINGS_GH_TOKEN` +- Required reviewers:不配置 +- Deployment branches:建议只允许 `main`,如果觉得麻烦可以先不配 + +如果你只想更省事,也可以把 `RUNTIME_SETTINGS_GH_TOKEN` 放在 repository secret。更推荐 Environment secret,因为它只给绑定该 Environment 的 job 用,安全边界更清楚。 + +## Token 权限 + +优先用 fine-grained PAT,只授权这些目标仓库: + +- `QuantStrategyLab/LongBridgePlatform` +- `QuantStrategyLab/InteractiveBrokersPlatform` +- `QuantStrategyLab/CharlesSchwabPlatform` +- `QuantStrategyLab/FirstradePlatform` + +需要的能力只有: + +- 读取和写入 GitHub Actions variables。 +- 如果要自动同步 Cloud Run,允许 dispatch 目标平台 workflow。 + +不需要: + +- `contents: write` +- issue/PR/release/packages 权限 +- organization admin 权限 + +## 日常切换流程 + +1. 打开 Actions 里的 `Manual Strategy Switch`。 +2. 填 `platform`、`target_name`、`strategy_profile`。 +3. 先保持 `apply=false` 跑一次,检查 preview。 +4. 没问题后再跑一次: + - 只写变量:`apply=true`,`confirm_apply=APPLY` + - 写变量并同步平台:`apply=true`,`trigger_platform_sync=true`,`confirm_apply=APPLY_AND_SYNC` + +这就是个人模式下的一键切换。不需要找 Codex,也不需要人工审批。 + +## 保留的安全门 + +这些防线不会增加太多操作成本,但能挡住常见误操作: + +- `apply=false` 默认只预览,不改远端。 +- `apply=true` 必须写确认词。 +- 没有 `RUNTIME_SETTINGS_GH_TOKEN` 时不能真实写入。 +- IBKR 会 patch 指定 target,不覆盖其他 IBKR 服务。 +- `extra_variables_json` 不能覆盖系统自动生成的核心变量。 +- `extra_variables_json` 会拒绝疑似 secret 的变量名,例如 `PASSWORD`、`TOKEN`、`API_KEY`、`ACCESS_KEY`、`CLIENT_SECRET`、`SECRET`。 + +## 网页端权限模型 + +网页端按个人模式做成“公开只读,登录可执行”: + +- 未登录或不在 allowlist:只能看页面、填参数、复制 preview,不能执行切换。 +- 已登录且 GitHub 用户名在 allowlist:页面启用“一键执行”,由后端触发 GitHub workflow。 +- 前端不保存 GitHub token,不保存 broker secret,不把敏感值写进 localStorage、URL 或日志。 +- 后端只做登录校验、allowlist 校验和 workflow dispatch,不直接写平台仓 variables,也不直接改 Cloud Run。 +- 后端使用 `RUNTIME_SETTINGS_DISPATCH_TOKEN` 触发 workflow;GitHub Actions 内部再使用 `RUNTIME_SETTINGS_GH_TOKEN` 写目标平台 variables。 +- 真正跨平台变量写入仍由 `Manual Strategy Switch` workflow 执行,继续复用 preview、确认词和 secret 变量名校验。 + +仓库内提供了 Cloudflare Worker 示例:`web/strategy-switch-console/worker.js`。部署后配置 `ALLOWED_GITHUB_LOGINS`,只有白名单里的 GitHub 账号能点击执行。 + +不建议做一个“网页密码 + 前端 token”或“网页密码 + 后端直接改配置”的方案。它看起来简单,但权限边界更差,也更容易在开源项目里泄漏高权限入口。 + +## 回滚 + +回滚也用同一个 workflow: + +1. 选择上一个稳定 `strategy_profile`。 +2. 保持同一个 `platform` 和 `target_name`。 +3. 运行 `apply=true`。 +4. 如果之前同步过 Cloud Run,这次也用 `APPLY_AND_SYNC`。 + +## 可选增强 + +以后资金规模变大,或者多人一起维护,再打开这些: + +- `runtime-strategy-switch` Environment required reviewers +- deployment branch 限制只允许 `main` +- token 90 天轮换 +- 独立记录每次切换的 Actions run URL diff --git a/docs/manual_strategy_switch_web.html b/docs/manual_strategy_switch_web.html new file mode 100644 index 0000000..7cd16db --- /dev/null +++ b/docs/manual_strategy_switch_web.html @@ -0,0 +1,12 @@ + + + + + + + Strategy Switch Console + + +

Open Strategy Switch Console

+ + diff --git a/docs/strategy_switch_admin_backend.md b/docs/strategy_switch_admin_backend.md new file mode 100644 index 0000000..c6699de --- /dev/null +++ b/docs/strategy_switch_admin_backend.md @@ -0,0 +1,55 @@ +# Strategy Switch Admin Backend + +Goal: keep the personal strategy switch console simple while avoiding code changes for every login or account dropdown update. + +## Current Mode + +- GitHub OAuth signs users in. +- `ALLOWED_GITHUB_LOGINS` controls who can dispatch a switch. +- `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON` controls signed-in account dropdowns. +- The GitHub dispatch token stays in Worker secrets and is never sent to the browser. + +This is enough for the first deployment. Its main limitation is that user and account changes require updating Worker secrets. + +## Recommended Admin Mode + +Keep GitHub OAuth and use an admin-only `/admin` page: + +- Bootstrap admins come from `STRATEGY_SWITCH_ADMIN_LOGINS`; keep your own GitHub login there. +- Admin actions: + - The current version verifies admin identity and shows configured account counts for the four platforms. + - After KV is connected, add or remove allowed GitHub logins. + - After KV is connected, edit account dropdowns for the four platforms. + - After KV is connected, review recent permission and account-config changes. +- Storage: + - Cloudflare KV namespace: `STRATEGY_SWITCH_CONFIG`. + - key `auth_config`: `allowed_logins` and `admin_logins`. + - key `account_options`: platform account dropdowns. + - key `audit_log`: recent admin changes. + +## Permission Rules + +- Not signed in: public read-only page. +- Signed in but not allowlisted: no switch, no admin page. +- Allowlisted: can dispatch switches. +- Admin-listed: can manage login permissions and account dropdowns. +- `STRATEGY_SWITCH_ADMIN_LOGINS` remains the break-glass admin source so you cannot remove yourself through the UI. + +## Security Boundary + +- The admin backend stores GitHub logins and account routing metadata only. +- Broker passwords, tokens, API keys, and cloud credentials stay out of this config. +- Admin writes use POST and the existing Worker same-origin checks. +- Sessions keep HttpOnly, Secure, SameSite=Lax, and HMAC-signed cookies. +- Dispatch tokens remain separate from admin config and are never readable from frontend code. +- Audit logs record time, admin login, and action type, but never secrets. + +## Rollout + +1. Ship the current secret-backed console. +2. The read-only `/admin` verification page is already available for `STRATEGY_SWITCH_ADMIN_LOGINS`. +3. Add Worker KV reads with secret fallback. +4. Add `/api/admin/config` write operations for admins. +5. Add audit logs and last-version rollback. + +This avoids a database, custom user system, or broad RBAC while still giving a practical backend for a personal open-source project. diff --git a/docs/strategy_switch_admin_backend.zh-CN.md b/docs/strategy_switch_admin_backend.zh-CN.md new file mode 100644 index 0000000..f8d7d6c --- /dev/null +++ b/docs/strategy_switch_admin_backend.zh-CN.md @@ -0,0 +1,54 @@ +# 策略切换登录权限后台方案 + +目标:保持个人量化系统足够简单,同时不用每次改权限或账号下拉都重新改代码。 + +## 当前模式 + +- GitHub OAuth 登录。 +- `ALLOWED_GITHUB_LOGINS` 控制谁能触发切换。 +- `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON` 控制登录后可选账号。 +- GitHub dispatch token 只在 Worker secret 里,前端拿不到。 + +这个模式可以先上线。它的缺点是:新增登录用户或账号 target 时,需要改 Worker secret。 + +## 推荐后台模式 + +保留 GitHub OAuth,使用一个只给管理员看的 `/admin` 页面: + +- 管理员:由 `STRATEGY_SWITCH_ADMIN_LOGINS` 启动配置,建议只放你自己的 GitHub login。 +- 可操作内容: + - 当前已支持验证管理员身份,并展示四个平台已加载账号数量。 + - 后续接 KV 后,可添加或移除允许登录的 GitHub 用户名。 + - 后续接 KV 后,可编辑四个平台的账号下拉配置。 + - 后续接 KV 后,可查看最近的权限和账号配置修改记录。 +- 存储: + - Cloudflare KV namespace:`STRATEGY_SWITCH_CONFIG`。 + - key `auth_config`:保存 `allowed_logins`、`admin_logins`。 + - key `account_options`:保存四个平台账号下拉。 + - key `audit_log`:保存最近 50 条管理操作。 + +## 权限规则 + +- 未登录:只能看公开只读页。 +- 登录但不在 allowlist:不能切换,也不能进入后台。 +- 登录且在 allowlist:可以一键切换。 +- 登录且在 admin list:可以进入后台管理权限和账号配置。 +- `STRATEGY_SWITCH_ADMIN_LOGINS` 是兜底管理员,不通过后台删除,避免把自己锁在外面。 + +## 安全边界 + +- 后台只管理 GitHub login 和账号路由信息,不保存 broker 密码、token、API key。 +- 所有后台写操作使用 POST,并复用当前 Worker 的 Same-Origin 校验。 +- session cookie 继续使用 HttpOnly、Secure、SameSite=Lax 和 HMAC 签名。 +- dispatch token 与后台配置分离;管理员能改账号路由,但不能在前端读取 token。 +- 每次后台修改写 audit log,至少记录时间、管理员 login、操作类型,不记录密钥。 + +## 分阶段落地 + +1. 先上线当前 secret 模式:页面一键切换、账号配置由 `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON` 提供。 +2. `/admin` 只读验证页已经具备:可验证 `STRATEGY_SWITCH_ADMIN_LOGINS` 管理权限,并展示已加载账号数量。 +3. 给 Worker 增加 KV 读取:优先读 KV,没有 KV 时回退到 secrets。 +4. 增加 `/api/admin/config` 写接口:管理员可更新 allowlist 和账号配置。 +5. 增加 audit log 与回滚:保留最近版本,改错后可以恢复。 + +这个方案不引入独立数据库、用户系统或复杂 RBAC,适合个人开源项目。真正的权限根仍然是你的 GitHub 账号和 Worker secret。 diff --git a/examples/targets/firstrade/live.example.json b/examples/targets/firstrade/live.example.json new file mode 100644 index 0000000..7acf83d --- /dev/null +++ b/examples/targets/firstrade/live.example.json @@ -0,0 +1,33 @@ +{ + "$schema": "../../../schemas/runtime-target.schema.json", + "target_id": "firstrade/live", + "description": "Example Firstrade repository-scoped deployment target.", + "github": { + "repository": "QuantStrategyLab/FirstradePlatform", + "variable_scope": "repository" + }, + "runtime_target": { + "platform_id": "firstrade", + "strategy_profile": "example_strategy_profile", + "dry_run_only": false, + "deployment_selector": "firstrade", + "account_selector": ["firstrade"], + "account_scope": "US", + "service_name": "firstrade-quant-service", + "execution_mode": "live" + }, + "plugin_mounts_variable": "FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON", + "plugin_mounts": [ + { + "strategy": "example_strategy_profile", + "plugin": "example_notification_plugin", + "signal_path": "gs://example-bucket/strategy-artifacts/example_strategy_profile/plugins/example_notification_plugin/latest_signal.json", + "enabled": true, + "expected_mode": "shadow", + "expected_schema_version": "example_notification_plugin.v1" + } + ], + "extra_variables": { + "FIRSTRADE_DRY_RUN_ONLY": "false" + } +} diff --git a/scripts/build_runtime_switch.py b/scripts/build_runtime_switch.py new file mode 100644 index 0000000..a3d54fa --- /dev/null +++ b/scripts/build_runtime_switch.py @@ -0,0 +1,413 @@ +#!/usr/bin/env python3 +"""Build a transient runtime target for a manual strategy switch.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from runtime_settings import SUPPORTED_PLATFORMS, compact_json, env_string, validate_target # noqa: E402 + + +DEFAULT_ARTIFACT_BUCKET_URI = "gs://qsl-runtime-logs-interactivebrokersquant" +MARKET_REGIME_CONTROL_PROFILES = frozenset( + { + "tqqq_growth_income", + "global_etf_rotation", + "russell_1000_multi_factor_defensive", + "mega_cap_leader_rotation_top50_balanced", + } +) +PLATFORM_DRY_RUN_VARIABLES = { + "schwab": "SCHWAB_DRY_RUN_ONLY", + "longbridge": "LONGBRIDGE_DRY_RUN_ONLY", + "ibkr": "IBKR_DRY_RUN_ONLY", + "firstrade": "FIRSTRADE_DRY_RUN_ONLY", +} +PLATFORM_RESERVED_CASH_RATIO_VARIABLES = { + "schwab": "SCHWAB_RESERVED_CASH_RATIO", + "longbridge": "LONGBRIDGE_RESERVED_CASH_RATIO", + "ibkr": "IBKR_RESERVED_CASH_RATIO", + "firstrade": "FIRSTRADE_RESERVED_CASH_RATIO", +} +PLATFORM_MIN_RESERVED_CASH_VARIABLES = { + "schwab": "SCHWAB_MIN_RESERVED_CASH_USD", + "longbridge": "LONGBRIDGE_MIN_RESERVED_CASH_USD", + "ibkr": "IBKR_MIN_RESERVED_CASH_USD", + "firstrade": "FIRSTRADE_MIN_RESERVED_CASH_USD", +} +DEFAULT_VARIABLE_SCOPE = { + "longbridge": "environment", + "ibkr": "repository", + "schwab": "repository", + "firstrade": "repository", +} +DEFAULT_SERVICE_NAME = { + "schwab": "charles-schwab-quant-service", + "firstrade": "firstrade-quant-service", +} +PLATFORM_ALIASES = { + "firsttrade": "firstrade", +} + + +def _normalize_platform(value: str) -> str: + platform = str(value or "").strip().lower() + platform = PLATFORM_ALIASES.get(platform, platform) + if platform not in SUPPORTED_PLATFORMS: + supported = ", ".join(sorted(SUPPORTED_PLATFORMS)) + raise ValueError(f"unsupported platform {value!r}; supported: {supported}") + return platform + + +def _normalize_target_name(value: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError("target_name is required") + return re.sub(r"[^A-Za-z0-9._=-]+", "-", text).strip("-") + + +def _deployment_selector_default(platform: str, target_name: str) -> str: + if platform == "firstrade": + return "firstrade" + return target_name.upper() if target_name.lower() in {"sg", "hk", "paper"} else target_name + + +def _account_scope_default(platform: str, deployment_selector: str) -> str: + if platform == "firstrade": + return "US" + return deployment_selector + + +def _account_selector_default(platform: str, account_scope: str) -> list[str]: + if platform == "firstrade": + return ["firstrade"] + return [account_scope] + + +def _default_service_name(platform: str, target_name: str) -> str: + if platform in DEFAULT_SERVICE_NAME: + return DEFAULT_SERVICE_NAME[platform] + normalized = target_name.lower() + if platform == "longbridge": + return f"longbridge-quant-{normalized}-service" + if platform == "ibkr": + return f"interactive-brokers-{normalized}-service" + raise ValueError(f"no default service_name for platform {platform!r}") + + +def _default_github_environment(platform: str, target_name: str, variable_scope: str) -> str | None: + if variable_scope != "environment": + return None + if platform == "longbridge": + return f"longbridge-{target_name.lower()}" + return target_name + + +def _split_csv(value: str | None) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.replace(";", ",").split(",") if item.strip()] + + +def _load_json_object(value: str, *, field_name: str) -> dict[str, Any]: + text = str(value or "").strip() + if not text: + return {} + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"{field_name} must be valid JSON") from exc + if not isinstance(payload, dict): + raise ValueError(f"{field_name} must decode to an object") + return payload + + +def _load_json_from_file(path: str | None, *, field_name: str) -> dict[str, Any]: + if not path: + return {} + text = Path(path).read_text(encoding="utf-8") + return _load_json_object(text, field_name=field_name) + + +def _parse_extra_variables(pairs: list[str], raw_json: str) -> dict[str, Any]: + extras = _load_json_object(raw_json, field_name="extra_variables_json") + for pair in pairs: + name, sep, value = pair.partition("=") + if not sep or not name.strip(): + raise ValueError(f"extra variable must be NAME=VALUE, got: {pair!r}") + extras[name.strip()] = value + return extras + + +def _auto_plugin_mounts(strategy_profile: str, artifact_bucket_uri: str) -> list[dict[str, Any]]: + if strategy_profile not in MARKET_REGIME_CONTROL_PROFILES: + return [] + prefix = artifact_bucket_uri.rstrip("/") + return [ + { + "strategy": strategy_profile, + "plugin": "market_regime_control", + "signal_path": ( + f"{prefix}/strategy-artifacts/us_equity/{strategy_profile}" + "/plugins/market_regime_control/latest_signal.json" + ), + "enabled": True, + "expected_mode": "shadow", + "expected_schema_version": "market_regime_control.v1", + } + ] + + +def _custom_plugin_mounts(raw_json: str) -> list[dict[str, Any]]: + text = str(raw_json or "").strip() + if not text: + return [] + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError("custom_plugin_mounts_json must be valid JSON") from exc + if isinstance(payload, dict): + payload = payload.get("strategy_plugins", payload.get("plugins")) + if not isinstance(payload, list): + raise ValueError("custom_plugin_mounts_json must be a list or object with strategy_plugins") + return [dict(item) for item in payload] + + +def _plugin_mounts(args: argparse.Namespace, strategy_profile: str) -> list[dict[str, Any]]: + mode = str(args.plugin_mode or "auto").strip().lower() + if mode == "none": + return [] + if mode == "auto": + return _auto_plugin_mounts(strategy_profile, args.artifact_bucket_uri) + if mode == "custom": + return _custom_plugin_mounts(args.custom_plugin_mounts_json) + raise ValueError(f"unsupported plugin_mode {args.plugin_mode!r}") + + +def _execution_mode_and_dry_run(raw_mode: str) -> tuple[str, bool]: + mode = str(raw_mode or "").strip().lower() + if mode == "live": + return "live", False + if mode in {"paper", "dry_run", "dry-run"}: + return "paper", True + raise ValueError("execution_mode must be live or paper") + + +def _build_runtime_target(args: argparse.Namespace) -> dict[str, Any]: + platform = _normalize_platform(args.platform) + target_name = _normalize_target_name(args.target_name) + execution_mode, dry_run_only = _execution_mode_and_dry_run(args.execution_mode) + deployment_selector = ( + args.deployment_selector.strip() + if args.deployment_selector + else _deployment_selector_default(platform, target_name) + ) + account_scope = ( + args.account_scope.strip() + if args.account_scope + else _account_scope_default(platform, deployment_selector) + ) + account_selector = _split_csv(args.account_selector) or _account_selector_default(platform, account_scope) + service_name = args.service_name.strip() if args.service_name else _default_service_name(platform, target_name) + runtime_target: dict[str, Any] = { + "platform_id": platform, + "strategy_profile": args.strategy_profile.strip().lower(), + "dry_run_only": dry_run_only, + "deployment_selector": deployment_selector, + "account_selector": account_selector, + "account_scope": account_scope, + "service_name": service_name, + "execution_mode": execution_mode, + } + execution_windows = _load_json_object(args.execution_windows_json, field_name="execution_windows_json") + if execution_windows: + runtime_target["execution_windows"] = execution_windows + return runtime_target + + +def _build_target_entry( + *, + platform: str, + runtime_target: dict[str, Any], + mounts_variable: str, + mounts: list[dict[str, Any]], + extra_variables: dict[str, Any], +) -> dict[str, Any]: + service_name = str(runtime_target["service_name"]) + entry: dict[str, Any] = { + "service": service_name, + "runtime_target": dict(runtime_target), + } + if platform == "ibkr": + entry["ACCOUNT_GROUP"] = runtime_target["account_scope"] + dry_run_variable = PLATFORM_DRY_RUN_VARIABLES.get(platform) + if dry_run_variable: + entry[dry_run_variable] = env_string(runtime_target["dry_run_only"]) + if mounts and mounts_variable: + entry[mounts_variable] = {"strategy_plugins": mounts} + entry.update(extra_variables) + return entry + + +def _patch_service_targets( + *, + current_payload: dict[str, Any], + platform: str, + runtime_target: dict[str, Any], + mounts_variable: str, + mounts: list[dict[str, Any]], + extra_variables: dict[str, Any], +) -> dict[str, Any]: + payload = dict(current_payload) + raw_entries = payload.get("targets") if isinstance(payload.get("targets"), list) else [] + entries = [dict(item) for item in raw_entries if isinstance(item, dict)] + service_name = str(runtime_target["service_name"]) + account_scope = str(runtime_target["account_scope"]) + replacement = _build_target_entry( + platform=platform, + runtime_target=runtime_target, + mounts_variable=mounts_variable, + mounts=mounts, + extra_variables=extra_variables, + ) + + replaced = False + for index, entry in enumerate(entries): + entry_runtime_target = entry.get("runtime_target") if isinstance(entry.get("runtime_target"), dict) else {} + candidates = { + str(entry.get("service") or "").strip(), + str(entry.get("service_name") or "").strip(), + str(entry_runtime_target.get("service_name") or "").strip(), + str(entry_runtime_target.get("account_scope") or "").strip(), + str(entry.get("ACCOUNT_GROUP") or "").strip(), + } + if service_name in candidates or account_scope in candidates: + entries[index] = {**entry, **replacement} + replaced = True + break + + if not replaced: + entries.append(replacement) + payload["targets"] = entries + return payload + + +def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: + platform = _normalize_platform(args.platform) + target_name = _normalize_target_name(args.target_name) + variable_scope = args.variable_scope or DEFAULT_VARIABLE_SCOPE[platform] + if variable_scope not in {"repository", "environment"}: + raise ValueError("variable_scope must be repository or environment") + github_environment = args.github_environment or _default_github_environment(platform, target_name, variable_scope) + runtime_target = _build_runtime_target(args) + mounts = _plugin_mounts(args, runtime_target["strategy_profile"]) + mounts_variable = f"{SUPPORTED_PLATFORMS[platform]['plugin_mounts_prefix']}STRATEGY_PLUGIN_MOUNTS_JSON" + extra_variables = _parse_extra_variables(args.extra_variable, args.extra_variables_json) + + if args.set_platform_dry_run_variable: + extra_variables[PLATFORM_DRY_RUN_VARIABLES[platform]] = env_string(runtime_target["dry_run_only"]) + if args.reserved_cash_ratio: + extra_variables[PLATFORM_RESERVED_CASH_RATIO_VARIABLES[platform]] = args.reserved_cash_ratio + if args.min_reserved_cash_usd: + extra_variables[PLATFORM_MIN_RESERVED_CASH_VARIABLES[platform]] = args.min_reserved_cash_usd + if args.income_threshold_usd: + extra_variables["INCOME_THRESHOLD_USD"] = args.income_threshold_usd + if args.qqqi_income_ratio: + extra_variables["QQQI_INCOME_RATIO"] = args.qqqi_income_ratio + + service_targets = _load_json_from_file( + args.existing_service_targets_json_file, + field_name="existing_service_targets_json_file", + ) + top_level_mounts = mounts + plugin_mounts_variable: str | None = mounts_variable if mounts else None + if service_targets: + patched_service_targets = _patch_service_targets( + current_payload=service_targets, + platform=platform, + runtime_target=runtime_target, + mounts_variable=mounts_variable, + mounts=mounts, + extra_variables=extra_variables, + ) + extra_variables = {"CLOUD_RUN_SERVICE_TARGETS_JSON": patched_service_targets} + top_level_mounts = [] + plugin_mounts_variable = None + + target: dict[str, Any] = { + "target_id": f"{platform}/{target_name}", + "description": "Generated by build_runtime_switch.py for manual workflow dispatch.", + "github": { + "repository": SUPPORTED_PLATFORMS[platform]["repository"], + "variable_scope": variable_scope, + }, + "runtime_target": runtime_target, + "extra_variables": extra_variables, + } + if github_environment: + target["github"]["environment"] = github_environment + if plugin_mounts_variable: + target["plugin_mounts_variable"] = plugin_mounts_variable + target["plugin_mounts"] = top_level_mounts + errors = validate_target(target) + if errors: + raise ValueError("; ".join(errors)) + return target + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--platform", required=True, choices=sorted((*SUPPORTED_PLATFORMS, *PLATFORM_ALIASES))) + parser.add_argument("--target-name", required=True) + parser.add_argument("--strategy-profile", required=True) + parser.add_argument("--execution-mode", choices=("live", "paper", "dry_run"), default="live") + parser.add_argument("--variable-scope", choices=("repository", "environment")) + parser.add_argument("--github-environment", default="") + parser.add_argument("--deployment-selector", default="") + parser.add_argument("--account-selector", default="") + parser.add_argument("--account-scope", default="") + parser.add_argument("--service-name", default="") + parser.add_argument("--execution-windows-json", default="") + parser.add_argument("--plugin-mode", choices=("auto", "none", "custom"), default="auto") + parser.add_argument("--custom-plugin-mounts-json", default="") + parser.add_argument("--artifact-bucket-uri", default=DEFAULT_ARTIFACT_BUCKET_URI) + parser.add_argument("--extra-variables-json", default="", help="JSON object of non-secret extra variables") + parser.add_argument("--extra-variable", action="append", default=[], help="NAME=VALUE non-secret extra variable") + parser.add_argument("--reserved-cash-ratio", default="") + parser.add_argument("--min-reserved-cash-usd", default="") + parser.add_argument("--income-threshold-usd", default="") + parser.add_argument("--qqqi-income-ratio", default="") + parser.add_argument("--existing-service-targets-json-file", default="") + parser.add_argument("--no-platform-dry-run-variable", dest="set_platform_dry_run_variable", action="store_false") + parser.set_defaults(set_platform_dry_run_variable=True) + parser.add_argument("--output", default="-", help="output path, or '-' for stdout") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + target = build_switch_target(args) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + payload = compact_json(target) + if args.output == "-": + print(payload) + else: + Path(args.output).write_text(payload + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/runtime_settings.py b/scripts/runtime_settings.py index db175d4..610c32e 100644 --- a/scripts/runtime_settings.py +++ b/scripts/runtime_settings.py @@ -21,6 +21,7 @@ "schwab": {"plugin_mounts_prefix": "SCHWAB_", "repository": "QuantStrategyLab/CharlesSchwabPlatform"}, "longbridge": {"plugin_mounts_prefix": "LONGBRIDGE_", "repository": "QuantStrategyLab/LongBridgePlatform"}, "ibkr": {"plugin_mounts_prefix": "IBKR_", "repository": "QuantStrategyLab/InteractiveBrokersPlatform"}, + "firstrade": {"plugin_mounts_prefix": "FIRSTRADE_", "repository": "QuantStrategyLab/FirstradePlatform"}, } RUNTIME_REQUIRED_FIELDS = ( "platform_id", @@ -37,7 +38,13 @@ "execution": {"live", "paper", "dry_run"}, } GENERATED_VARIABLES = {"RUNTIME_TARGET_JSON", "STRATEGY_PROFILE"} -SECRET_MARKERS = ("PASSWORD", "PRIVATE_KEY", "TOKEN", "API_KEY") +SECRET_MARKERS = ("PASSWORD", "PRIVATE_KEY", "TOKEN", "API_KEY", "ACCESS_KEY", "CLIENT_SECRET", "SECRET") +PLATFORM_DRY_RUN_VARIABLES = { + "schwab": "SCHWAB_DRY_RUN_ONLY", + "longbridge": "LONGBRIDGE_DRY_RUN_ONLY", + "ibkr": "IBKR_DRY_RUN_ONLY", + "firstrade": "FIRSTRADE_DRY_RUN_ONLY", +} @dataclass(frozen=True) @@ -89,6 +96,13 @@ def load_target(path: Path) -> dict[str, Any]: return json.load(handle) +def display_path(path: Path) -> str: + try: + return str(path.relative_to(ROOT)) + except ValueError: + return str(path) + + def load_local_policy() -> dict[str, Any]: if not LOCAL_POLICY_PATH.exists(): return {} @@ -117,7 +131,15 @@ def target_path_id(path: Path) -> str | None: def is_secret_variable_name(name: str) -> bool: upper_name = name.upper() - if upper_name.endswith("_SECRET_NAME"): + allowed_secret_pointer_suffixes = ( + "_SECRET_ID", + "_SECRET_NAME", + "_SECRET_REF", + "_SECRET_RESOURCE", + "_SECRET_RESOURCE_NAME", + "_SECRET_VERSION", + ) + if upper_name.endswith(allowed_secret_pointer_suffixes): return False return any(marker in upper_name for marker in SECRET_MARKERS) @@ -197,7 +219,8 @@ def validate_runtime_target(target: dict[str, Any], errors: list[str]) -> None: offset_minutes = window["offset_minutes"] if not isinstance(offset_minutes, int) or offset_minutes < 0: errors.append( - f"runtime_target.execution_windows.{window_name}.offset_minutes must be a non-negative integer" + "runtime_target.execution_windows." + f"{window_name}.offset_minutes must be a non-negative integer" ) mode = window.get("mode") if mode is not None and mode not in allowed_modes: @@ -301,9 +324,11 @@ def validate_extra_variables(target: dict[str, Any], errors: list[str]) -> None: runtime_target = target.get("runtime_target") if isinstance(target.get("runtime_target"), dict) else {} dry_run_only = runtime_target.get("dry_run_only") - longbridge_dry_run = extra_variables.get("LONGBRIDGE_DRY_RUN_ONLY") - if longbridge_dry_run is not None and env_string(longbridge_dry_run).lower() != env_string(dry_run_only): - errors.append("extra_variables.LONGBRIDGE_DRY_RUN_ONLY must match runtime_target.dry_run_only") + platform_id = runtime_target.get("platform_id") + dry_run_variable = PLATFORM_DRY_RUN_VARIABLES.get(str(platform_id or "")) + platform_dry_run = extra_variables.get(dry_run_variable) if dry_run_variable else None + if platform_dry_run is not None and env_string(platform_dry_run).lower() != env_string(dry_run_only): + errors.append(f"extra_variables.{dry_run_variable} must match runtime_target.dry_run_only") def validate_target(target: dict[str, Any], path: Path | None = None) -> list[str]: @@ -325,7 +350,10 @@ def validate_target(target: dict[str, Any], path: Path | None = None) -> list[st runtime_target = target.get("runtime_target") if isinstance(target.get("runtime_target"), dict) else {} github = target.get("github") if isinstance(target.get("github"), dict) else {} platform_id = runtime_target.get("platform_id") - if platform_id in SUPPORTED_PLATFORMS and github.get("repository") != SUPPORTED_PLATFORMS[platform_id]["repository"]: + if ( + platform_id in SUPPORTED_PLATFORMS + and github.get("repository") != SUPPORTED_PLATFORMS[platform_id]["repository"] + ): errors.append( "github.repository does not match platform " f"{platform_id}: expected {SUPPORTED_PLATFORMS[platform_id]['repository']}" @@ -381,11 +409,11 @@ def command_validate(args: argparse.Namespace) -> int: errors = validate_target(target, path) if errors: had_errors = True - print(f"FAIL {path.relative_to(ROOT)}", file=sys.stderr) + print(f"FAIL {display_path(path)}", file=sys.stderr) for error in errors: print(f" - {error}", file=sys.stderr) else: - print(f"OK {path.relative_to(ROOT)}") + print(f"OK {display_path(path)}") return 1 if had_errors else 0 diff --git a/scripts/sync_strategy_switch_page_asset.py b/scripts/sync_strategy_switch_page_asset.py new file mode 100644 index 0000000..5a7eee6 --- /dev/null +++ b/scripts/sync_strategy_switch_page_asset.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Embed docs/index.html as a Cloudflare Worker module asset.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "docs" / "index.html" +TARGET = ROOT / "web" / "strategy-switch-console" / "page_asset.js" + + +def main() -> int: + html = SOURCE.read_text(encoding="utf-8") + payload = ( + "// Generated by scripts/sync_strategy_switch_page_asset.py; do not edit by hand.\n" + f"export const PAGE_HTML = {json.dumps(html, ensure_ascii=False)};\n" + ) + TARGET.write_text(payload, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_runtime_settings.py b/tests/test_runtime_settings.py index b22543d..2e4ae02 100644 --- a/tests/test_runtime_settings.py +++ b/tests/test_runtime_settings.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import json import sys import unittest from pathlib import Path @@ -14,6 +15,13 @@ sys.modules[SPEC.name] = runtime_settings SPEC.loader.exec_module(runtime_settings) +SWITCH_MODULE_PATH = ROOT / "scripts" / "build_runtime_switch.py" +SWITCH_SPEC = importlib.util.spec_from_file_location("build_runtime_switch", SWITCH_MODULE_PATH) +build_runtime_switch = importlib.util.module_from_spec(SWITCH_SPEC) +assert SWITCH_SPEC.loader is not None +sys.modules[SWITCH_SPEC.name] = build_runtime_switch +SWITCH_SPEC.loader.exec_module(build_runtime_switch) + class RuntimeSettingsTest(unittest.TestCase): def load_target(self, relative_path: str): @@ -37,6 +45,7 @@ def test_example_targets_have_matching_plugin_mount(self): for relative_path in ( "examples/targets/schwab/live.example.json", "examples/targets/longbridge/sg.example.json", + "examples/targets/firstrade/live.example.json", ): with self.subTest(relative_path=relative_path): _, target = self.load_target(relative_path) @@ -53,7 +62,10 @@ def test_plugin_mount_schema_version_is_rendered_for_platform_parser(self): _, target = self.load_target("examples/targets/schwab/live.example.json") assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} - self.assertIn('"expected_schema_version":"example_notification_plugin.v1"', assignments["SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON"]) + self.assertIn( + '"expected_schema_version":"example_notification_plugin.v1"', + assignments["SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON"], + ) def test_plugin_mount_schema_version_must_be_non_empty_string(self): _, target = self.load_target("examples/targets/schwab/live.example.json") @@ -73,6 +85,29 @@ def test_generated_variables_cannot_be_overridden(self): runtime_settings.validate_target(target), ) + def test_extra_variables_reject_secret_values_but_allow_secret_pointers(self): + _, target = self.load_target("examples/targets/schwab/live.example.json") + target["extra_variables"] = { + "BROKER_ACCESS_TOKEN": "not-allowed", + "EMAIL_PASSWORD": "not-allowed", + "BROKER_SECRET_NAME": "allowed-secret-manager-name", + } + + errors = runtime_settings.validate_target(target) + + self.assertIn( + "extra_variables.BROKER_ACCESS_TOKEN looks like a secret and must not be stored here", + errors, + ) + self.assertIn( + "extra_variables.EMAIL_PASSWORD looks like a secret and must not be stored here", + errors, + ) + self.assertNotIn( + "extra_variables.BROKER_SECRET_NAME looks like a secret and must not be stored here", + errors, + ) + def test_longbridge_dry_run_flag_must_match_runtime_target(self): _, target = self.load_target("examples/targets/longbridge/sg.example.json") target["extra_variables"]["LONGBRIDGE_DRY_RUN_ONLY"] = "true" @@ -82,6 +117,184 @@ def test_longbridge_dry_run_flag_must_match_runtime_target(self): runtime_settings.validate_target(target), ) + def test_firstrade_dry_run_flag_must_match_runtime_target(self): + _, target = self.load_target("examples/targets/firstrade/live.example.json") + target["extra_variables"]["FIRSTRADE_DRY_RUN_ONLY"] = "true" + + self.assertIn( + "extra_variables.FIRSTRADE_DRY_RUN_ONLY must match runtime_target.dry_run_only", + runtime_settings.validate_target(target), + ) + + def test_build_switch_target_defaults_longbridge_sg_tqqq(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "longbridge", + "--target-name", + "sg", + "--strategy-profile", + "tqqq_growth_income", + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + + self.assertEqual(target["github"]["repository"], "QuantStrategyLab/LongBridgePlatform") + self.assertEqual(target["github"]["variable_scope"], "environment") + self.assertEqual(target["github"]["environment"], "longbridge-sg") + self.assertEqual(target["runtime_target"]["service_name"], "longbridge-quant-sg-service") + self.assertEqual(target["runtime_target"]["account_scope"], "SG") + self.assertEqual(assignments["STRATEGY_PROFILE"], "tqqq_growth_income") + self.assertEqual(assignments["LONGBRIDGE_DRY_RUN_ONLY"], "false") + plugin_payload = json.loads(assignments["LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON"]) + self.assertEqual(plugin_payload["strategy_plugins"][0]["plugin"], "market_regime_control") + self.assertEqual(plugin_payload["strategy_plugins"][0]["expected_schema_version"], "market_regime_control.v1") + + def test_build_switch_target_defaults_schwab_repository_scope(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "schwab", + "--target-name", + "live", + "--strategy-profile", + "soxl_soxx_trend_income", + "--plugin-mode", + "none", + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + + self.assertEqual(target["github"]["repository"], "QuantStrategyLab/CharlesSchwabPlatform") + self.assertEqual(target["github"]["variable_scope"], "repository") + self.assertNotIn("environment", target["github"]) + self.assertEqual(target["runtime_target"]["service_name"], "charles-schwab-quant-service") + self.assertEqual(assignments["SCHWAB_DRY_RUN_ONLY"], "false") + self.assertNotIn("SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON", assignments) + + def test_build_switch_target_defaults_firstrade_repository_scope(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "firsttrade", + "--target-name", + "live", + "--strategy-profile", + "tqqq_growth_income", + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + + self.assertEqual(target["github"]["repository"], "QuantStrategyLab/FirstradePlatform") + self.assertEqual(target["github"]["variable_scope"], "repository") + self.assertEqual(target["runtime_target"]["platform_id"], "firstrade") + self.assertEqual(target["runtime_target"]["deployment_selector"], "firstrade") + self.assertEqual(target["runtime_target"]["account_selector"], ["firstrade"]) + self.assertEqual(target["runtime_target"]["account_scope"], "US") + self.assertEqual(target["runtime_target"]["service_name"], "firstrade-quant-service") + self.assertEqual(assignments["FIRSTRADE_DRY_RUN_ONLY"], "false") + self.assertEqual(assignments["STRATEGY_PROFILE"], "tqqq_growth_income") + plugin_payload = json.loads(assignments["FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON"]) + self.assertEqual(plugin_payload["strategy_plugins"][0]["plugin"], "market_regime_control") + + def test_build_switch_target_rejects_secret_extra_variable(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "firstrade", + "--target-name", + "live", + "--strategy-profile", + "tqqq_growth_income", + "--extra-variables-json", + '{"BROKER_API_KEY":"not-allowed"}', + ] + ) + + with self.assertRaisesRegex(ValueError, "BROKER_API_KEY looks like a secret"): + build_runtime_switch.build_switch_target(args) + + def test_build_switch_target_patches_ibkr_service_targets_json(self): + existing = { + "defaults": {"NOTIFY_LANG": "zh"}, + "targets": [ + { + "service": "interactive-brokers-live-u1599-tqqq-service", + "ACCOUNT_GROUP": "live-u1599-tqqq", + "runtime_target": { + "platform_id": "ibkr", + "strategy_profile": "old_strategy", + "dry_run_only": False, + "deployment_selector": "live-u1599-tqqq", + "account_selector": ["U15998061"], + "account_scope": "live-u1599-tqqq", + "service_name": "interactive-brokers-live-u1599-tqqq-service", + "execution_mode": "live", + }, + }, + { + "service": "interactive-brokers-live-u1660-soxl-service", + "ACCOUNT_GROUP": "live-u1660-soxl", + "runtime_target": { + "platform_id": "ibkr", + "strategy_profile": "soxl_soxx_trend_income", + "dry_run_only": False, + "deployment_selector": "live-u1660-soxl", + "account_selector": ["U16608560"], + "account_scope": "live-u1660-soxl", + "service_name": "interactive-brokers-live-u1660-soxl-service", + "execution_mode": "live", + }, + }, + ], + } + path = ROOT / ".pytest_runtime_service_targets.json" + path.write_text(runtime_settings.compact_json(existing), encoding="utf-8") + self.addCleanup(lambda: path.unlink(missing_ok=True)) + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "live-u1599-tqqq", + "--strategy-profile", + "tqqq_growth_income", + "--account-selector", + "U15998061", + "--service-name", + "interactive-brokers-live-u1599-tqqq-service", + "--existing-service-targets-json-file", + str(path), + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + patched = json.loads(assignments["CLOUD_RUN_SERVICE_TARGETS_JSON"]) + patched_targets = patched["targets"] + + self.assertEqual(len(patched_targets), 2) + selected = patched_targets[0] + untouched = patched_targets[1] + self.assertEqual(selected["runtime_target"]["strategy_profile"], "tqqq_growth_income") + self.assertEqual(selected["IBKR_DRY_RUN_ONLY"], "false") + self.assertEqual( + selected["IBKR_STRATEGY_PLUGIN_MOUNTS_JSON"]["strategy_plugins"][0]["plugin"], + "market_regime_control", + ) + self.assertEqual(untouched["runtime_target"]["strategy_profile"], "soxl_soxx_trend_income") + if __name__ == "__main__": unittest.main() diff --git a/web/strategy-switch-console/README.md b/web/strategy-switch-console/README.md new file mode 100644 index 0000000..ca69e25 --- /dev/null +++ b/web/strategy-switch-console/README.md @@ -0,0 +1,129 @@ +# Strategy Switch Console Worker + +This is the authenticated backend for the personal strategy switch console. It is intentionally thin: + +- Visitors who are not signed in, or are not in the allowlist, can only view the public page. +- Allowlisted GitHub logins can select an account from the dropdown and click `Switch now`; the Worker triggers the GitHub Actions workflow server-side. +- Tokens stay in Worker secrets and GitHub Actions environment secrets. They are not sent to the browser or committed to the repository. + +## Required Secrets + +```text +GITHUB_CLIENT_ID +GITHUB_CLIENT_SECRET +SESSION_SECRET +RUNTIME_SETTINGS_DISPATCH_TOKEN +ALLOWED_GITHUB_LOGINS +STRATEGY_SWITCH_ADMIN_LOGINS +``` + +Optional variables: + +```text +RUNTIME_SETTINGS_REPO=QuantStrategyLab/QuantRuntimeSettings +RUNTIME_SETTINGS_WORKFLOW=manual-strategy-switch.yml +RUNTIME_SETTINGS_REF=main +STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON={"longbridge":[{"key":"hk","label":"hk","target_name":"hk","account_selector":"HK"},{"key":"sg","label":"sg","target_name":"sg","account_selector":"SG"},{"key":"paper","label":"paper","target_name":"paper","account_selector":"PAPER"}],"ibkr":[{"key":"u0000000","label":"u0000000","target_name":"u0000000","account_selector":"u0000000"}],"schwab":[{"key":"default","label":"default","target_name":"default"}],"firstrade":[{"key":"default","label":"default","target_name":"default"}]} +``` + +`ALLOWED_GITHUB_LOGINS` and `STRATEGY_SWITCH_ADMIN_LOGINS` are comma-separated lists: + +```text +your-github-login +``` + +The login entrypoint is `/login` on the Worker domain. When the Worker is available, the page header shows the GitHub sign-in link. After sign-in, `/api/session` returns: + +```json +{ + "authenticated": true, + "login": "your-github-login", + "allowed": true, + "admin": true +} +``` + +`admin=true` means the login is listed in `STRATEGY_SWITCH_ADMIN_LOGINS`. You can also open `/admin` directly to verify admin permission; non-admin users receive 403. + +## Page Asset + +`worker.js` serves the same UI as `docs/index.html` through `page_asset.js`. + +After editing `docs/index.html`, regenerate the asset: + +```bash +python3 scripts/sync_strategy_switch_page_asset.py +``` + +Deploy `worker.js` and `page_asset.js` together. + +## Account Dropdowns + +The public page only ships sample targets. After sign-in, switching stays disabled until the Worker loads private account options; the Worker also rejects dispatches without matching private account config. Copy the example and fill in your real target/account routes: + +```bash +cp web/strategy-switch-console/account-options.example.json /tmp/strategy-switch-accounts.json +``` + +Store it as a Worker secret: + +```bash +cd web/strategy-switch-console +wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-accounts.json +``` + +Each account item supports: + +```json +{ + "key": "u0000000", + "label": "u0000000", + "target_name": "u0000000", + "account_selector": "u0000000", + "service_name": "interactive-brokers-u0000000-service" +} +``` + +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. + +## GitHub OAuth App + +Create a GitHub OAuth App: + +- Homepage URL: your Worker URL +- Authorization callback URL: `https://your-worker-domain/callback` + +Store the client ID and client secret in Worker secrets. + +## Deploy With Wrangler + +Copy the example config: + +```bash +cp web/strategy-switch-console/wrangler.toml.example web/strategy-switch-console/wrangler.toml +``` + +Set secrets: + +```bash +cd web/strategy-switch-console +wrangler secret put GITHUB_CLIENT_ID +wrangler secret put GITHUB_CLIENT_SECRET +wrangler secret put SESSION_SECRET +wrangler secret put RUNTIME_SETTINGS_DISPATCH_TOKEN +wrangler secret put ALLOWED_GITHUB_LOGINS +wrangler secret put STRATEGY_SWITCH_ADMIN_LOGINS +wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-accounts.json +``` + +Deploy: + +```bash +wrangler deploy +``` + +## Token Scope + +`RUNTIME_SETTINGS_DISPATCH_TOKEN` only needs permission to dispatch workflows in the `QuantRuntimeSettings` repository. Cross-platform variable writes still happen inside `Manual Strategy Switch` with the GitHub Actions environment secret `RUNTIME_SETTINGS_GH_TOKEN`. + +Configure `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON` as a secret if it contains real account routes. It is returned only after an allowlisted login. Keep broker, email, cloud, API key, and token values out of this config. diff --git a/web/strategy-switch-console/README.zh-CN.md b/web/strategy-switch-console/README.zh-CN.md new file mode 100644 index 0000000..a4a26c2 --- /dev/null +++ b/web/strategy-switch-console/README.zh-CN.md @@ -0,0 +1,147 @@ +# 策略切换控制台 + +这是个人量化系统的登录版网页控制台示例。它用 Cloudflare Worker 提供一个很薄的后端: + +- 未登录或不在 allowlist:只能查看公开页面,不能触发切换。 +- 已登录且 GitHub 用户名在 allowlist:可以从账号下拉框选择目标,然后点击“一键切换”,由 Worker 服务端触发 GitHub Actions workflow。 +- GitHub token 只放在 Worker secret 中,不进入前端、不写入开源代码。 + +## 必要配置 + +Worker 需要这些环境变量或 secret: + +```text +GITHUB_CLIENT_ID +GITHUB_CLIENT_SECRET +SESSION_SECRET +RUNTIME_SETTINGS_DISPATCH_TOKEN +ALLOWED_GITHUB_LOGINS +STRATEGY_SWITCH_ADMIN_LOGINS +``` + +可选: + +```text +RUNTIME_SETTINGS_REPO=QuantStrategyLab/QuantRuntimeSettings +RUNTIME_SETTINGS_WORKFLOW=manual-strategy-switch.yml +RUNTIME_SETTINGS_REF=main +STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON={"longbridge":[{"key":"hk","label":"hk","target_name":"hk","account_selector":"HK"},{"key":"sg","label":"sg","target_name":"sg","account_selector":"SG"},{"key":"paper","label":"paper","target_name":"paper","account_selector":"PAPER"}],"ibkr":[{"key":"u0000000","label":"u0000000","target_name":"u0000000","account_selector":"u0000000"}],"schwab":[{"key":"default","label":"default","target_name":"default"}],"firstrade":[{"key":"default","label":"default","target_name":"default"}]} +``` + +`ALLOWED_GITHUB_LOGINS` 和 `STRATEGY_SWITCH_ADMIN_LOGINS` 用英文逗号分隔,例如: + +```text +your-github-login +``` + +登录入口是 Worker 域名下的 `/login`,页面顶部会在 Worker 可用时显示“登录 GitHub”。登录成功后访问 `/api/session` 会返回: + +```json +{ + "authenticated": true, + "login": "your-github-login", + "allowed": true, + "admin": true +} +``` + +`admin=true` 表示该账号在 `STRATEGY_SWITCH_ADMIN_LOGINS` 中。也可以直接访问 `/admin` 验证管理权限;非管理员会返回 403。 + +## 文件结构 + +```text +worker.js +page_asset.js +wrangler.toml.example +``` + +`worker.js` 会复用 `docs/index.html` 的同一套页面。改完页面后运行: + +```bash +python3 scripts/sync_strategy_switch_page_asset.py +``` + +这会重新生成 `web/strategy-switch-console/page_asset.js`。部署 Worker 时需要同时带上 `worker.js` 和 `page_asset.js`。 + +## 账号下拉配置 + +公开页面只带示例 target,登录后如果没有加载私有账号配置,“一键切换”仍会保持禁用,Worker 后端也会拒绝 dispatch,避免账号不匹配。复制示例文件后填入你的真实 target/account route: + +```bash +cp web/strategy-switch-console/account-options.example.json /tmp/strategy-switch-accounts.json +``` + +然后把 JSON 作为 Worker secret: + +```bash +cd web/strategy-switch-console +wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-accounts.json +``` + +每个账号项支持这些字段: + +```json +{ + "key": "u0000000", + "label": "u0000000", + "target_name": "u0000000", + "account_selector": "u0000000", + "service_name": "interactive-brokers-u0000000-service" +} +``` + +Worker 会校验 dispatch 参数必须匹配这里的某个账号项。只放路由信息,不放 broker 密码、token、API key。 + +## GitHub OAuth App + +创建 GitHub OAuth App: + +- Homepage URL:Worker 域名 +- Authorization callback URL:`https://你的域名/callback` + +把 OAuth App 的 client id 和 client secret 配到 Worker。 + +## Cloudflare Worker 部署 + +复制示例配置: + +```bash +cp web/strategy-switch-console/wrangler.toml.example web/strategy-switch-console/wrangler.toml +``` + +进入目录后设置 secrets: + +```bash +cd web/strategy-switch-console +wrangler secret put GITHUB_CLIENT_ID +wrangler secret put GITHUB_CLIENT_SECRET +wrangler secret put SESSION_SECRET +wrangler secret put RUNTIME_SETTINGS_DISPATCH_TOKEN +wrangler secret put ALLOWED_GITHUB_LOGINS +wrangler secret put STRATEGY_SWITCH_ADMIN_LOGINS +wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-accounts.json +``` + +部署: + +```bash +wrangler deploy +``` + +## Token 权限 + +`RUNTIME_SETTINGS_DISPATCH_TOKEN` 只需要能触发 `QuantRuntimeSettings` 仓库的 workflow。实际跨平台 variables 写入仍由 `Manual Strategy Switch` workflow 内部使用 GitHub Actions 环境里的 `RUNTIME_SETTINGS_GH_TOKEN` 执行。 + +`STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON` 建议作为 secret 配置,这样真实账号下拉项只会在登录且通过 allowlist 后返回给前端。它只放账号路由信息,不要放 broker、email、cloud、API key 等密钥。 + +## 操作流程 + +1. 访问控制台页面。 +2. 未登录时只能查看公开示例,“一键切换”按钮禁用。 +3. 点击“登录 GitHub”,也可以直接访问 `/login`。 +4. 如果登录账号在 `ALLOWED_GITHUB_LOGINS` 或 `STRATEGY_SWITCH_ADMIN_LOGINS`,且账号配置已加载,按钮启用。 +5. 顶部只保留“登录管理”入口;如果登录账号在 `STRATEGY_SWITCH_ADMIN_LOGINS`,点击后进入 `/admin` 验证管理权限。 +6. 选择平台、账号、策略和模式后点击“一键切换”。 +7. 页面返回 GitHub Actions 链接,用于查看运行结果。 + +这个模式适合个人系统:不需要审批人,但能避免公开网页被任何人直接切换。 diff --git a/web/strategy-switch-console/account-options.example.json b/web/strategy-switch-console/account-options.example.json new file mode 100644 index 0000000..ce21ea8 --- /dev/null +++ b/web/strategy-switch-console/account-options.example.json @@ -0,0 +1,45 @@ +{ + "longbridge": [ + { + "key": "hk", + "label": "hk", + "target_name": "hk", + "account_selector": "HK" + }, + { + "key": "sg", + "label": "sg", + "target_name": "sg", + "account_selector": "SG" + }, + { + "key": "paper", + "label": "paper", + "target_name": "paper", + "account_selector": "PAPER" + } + ], + "ibkr": [ + { + "key": "u0000000", + "label": "u0000000", + "target_name": "u0000000", + "account_selector": "u0000000", + "service_name": "interactive-brokers-u0000000-service" + } + ], + "schwab": [ + { + "key": "default", + "label": "default", + "target_name": "default" + } + ], + "firstrade": [ + { + "key": "default", + "label": "default", + "target_name": "default" + } + ] +} diff --git a/web/strategy-switch-console/page_asset.js b/web/strategy-switch-console/page_asset.js new file mode 100644 index 0000000..5d500b4 --- /dev/null +++ b/web/strategy-switch-console/page_asset.js @@ -0,0 +1,2 @@ +// Generated by scripts/sync_strategy_switch_page_asset.py; do not edit by hand. +export const PAGE_HTML = "\n\n\n \n \n \n QuantRuntimeSettings Strategy Switch\n \n\n\n
\n
\n

策略切换

\n

选平台、目标账号和策略,一次执行完成切换。

\n
\n
\n 登录管理\n
\n
\n\n
\n \n\n
\n
\n
\n 当前平台\n

LongBridge

\n
\n\n
\n \n\n \n\n
\n 模式\n
\n \n \n
\n
\n
\n\n
\n \n

公开页面只能查看;登录版校验账号权限后才会触发 workflow。

\n

\n
\n
\n\n \n
\n
\n\n \n\n\n"; diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js new file mode 100644 index 0000000..7eef666 --- /dev/null +++ b/web/strategy-switch-console/worker.js @@ -0,0 +1,563 @@ +import { PAGE_HTML } from "./page_asset.js"; + +const DEFAULT_REPOSITORY = "QuantStrategyLab/QuantRuntimeSettings"; +const DEFAULT_WORKFLOW = "manual-strategy-switch.yml"; +const SESSION_COOKIE = "qsl_switch_session"; +const OAUTH_STATE_COOKIE = "qsl_switch_oauth_state"; +const SESSION_TTL_SECONDS = 8 * 60 * 60; + +const SUPPORTED_PLATFORMS = ["longbridge", "ibkr", "schwab", "firstrade"]; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + try { + if (url.pathname === "/login") return startLogin(request, env); + if (url.pathname === "/callback") return finishLogin(request, env); + if (url.pathname === "/admin") return adminPage(request, env); + if (url.pathname === "/api/session") return json(await sessionPayload(request, env)); + if (url.pathname === "/api/config") return json(await configPayload(request, env)); + if (url.pathname === "/api/logout" && request.method === "POST") return logout(request); + if (url.pathname === "/api/switch" && request.method === "POST") return dispatchSwitch(request, env); + return html(PAGE_HTML); + } catch (error) { + return json({ ok: false, error: error.message || "unexpected error" }, 500); + } + }, +}; + +async function startLogin(request, env) { + requireEnv(env, "GITHUB_CLIENT_ID"); + const url = new URL(request.url); + const state = randomToken(); + const authorizeUrl = new URL("https://github.com/login/oauth/authorize"); + authorizeUrl.searchParams.set("client_id", env.GITHUB_CLIENT_ID); + authorizeUrl.searchParams.set("redirect_uri", `${url.origin}/callback`); + authorizeUrl.searchParams.set("scope", "read:user"); + authorizeUrl.searchParams.set("state", state); + return redirect(authorizeUrl.toString(), { + "Set-Cookie": cookie(OAUTH_STATE_COOKIE, state, 600), + }); +} + +async function finishLogin(request, env) { + requireEnv(env, "GITHUB_CLIENT_ID"); + requireEnv(env, "GITHUB_CLIENT_SECRET"); + requireEnv(env, "SESSION_SECRET"); + + const url = new URL(request.url); + const code = url.searchParams.get("code") || ""; + const state = url.searchParams.get("state") || ""; + const cookies = parseCookies(request.headers.get("Cookie") || ""); + if (!code || !state || cookies[OAUTH_STATE_COOKIE] !== state) { + return html(renderMessage("登录失败", "OAuth state 校验失败,请重新登录。"), 400, clearOAuthCookie()); + } + + const tokenResponse = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + code, + redirect_uri: `${url.origin}/callback`, + }), + }); + const tokenPayload = await tokenResponse.json(); + if (!tokenResponse.ok || !tokenPayload.access_token) { + return html(renderMessage("登录失败", "GitHub token exchange 失败。"), 502, clearOAuthCookie()); + } + + const userResponse = await fetch("https://api.github.com/user", { + headers: githubHeaders(tokenPayload.access_token), + }); + const user = await userResponse.json(); + const login = String(user.login || "").toLowerCase(); + if (!userResponse.ok || !login) { + return html(renderMessage("登录失败", "无法读取 GitHub 用户。"), 502, clearOAuthCookie()); + } + + if (!isAllowedLogin(login, env)) { + return html(renderMessage("没有权限", `${login} 不在允许登录名单中。`), 403, clearOAuthCookie()); + } + + const session = await makeSession(login, env); + return redirect("/", { + "Set-Cookie": [ + cookie(SESSION_COOKIE, session, SESSION_TTL_SECONDS), + clearCookie(OAUTH_STATE_COOKIE), + ], + }); +} + +async function sessionPayload(request, env) { + const session = await readSession(request, env); + return { + authenticated: Boolean(session), + login: session?.login || null, + allowed: Boolean(session?.allowed), + admin: Boolean(session?.admin), + }; +} + +async function adminPage(request, env) { + const session = await readSession(request, env); + if (!session) return redirect("/login"); + if (!session.admin) { + return html(renderMessage("没有管理权限", `${session.login} 不在 STRATEGY_SWITCH_ADMIN_LOGINS 中。`), 403); + } + + const configuredPlatforms = parseAccountOptions(env.STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON || "") || {}; + const accountRows = SUPPORTED_PLATFORMS.map((platform) => { + const count = Array.isArray(configuredPlatforms[platform]) ? configuredPlatforms[platform].length : 0; + return `${escapeHtml(platform)}${count}`; + }).join(""); + return html(` + + +Strategy Switch Admin + +
+

Strategy Switch Admin

+

Signed in as ${escapeHtml(session.login)}. Admin permission is verified by STRATEGY_SWITCH_ADMIN_LOGINS.

+

Account options

+${accountRows}
PlatformConfigured accounts
+

Next step: connect Cloudflare KV to edit allowlist and account options here. Current version verifies admin access and shows loaded private account config counts.

+

Back to switch console

+
+`); +} + +async function configPayload(request, env) { + const session = await readSession(request, env); + if (!session?.allowed) return { accountOptions: null }; + return { + accountOptions: parseAccountOptions(env.STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON || ""), + }; +} + +function logout(request) { + requireSameOrigin(request); + return json({ ok: true }, 200, { + "Set-Cookie": clearCookie(SESSION_COOKIE), + }); +} + +async function dispatchSwitch(request, env) { + requireEnv(env, "RUNTIME_SETTINGS_DISPATCH_TOKEN"); + requireSameOrigin(request); + const session = await readSession(request, env); + if (!session?.allowed) return json({ ok: false, error: "login required" }, 401); + + const rawInput = await request.json(); + const inputs = normalizeSwitchInputs(rawInput); + assertSwitchIntent(inputs); + assertConfiguredAccount(inputs, parseAccountOptions(env.STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON || "")); + const repository = env.RUNTIME_SETTINGS_REPO || DEFAULT_REPOSITORY; + const workflow = env.RUNTIME_SETTINGS_WORKFLOW || DEFAULT_WORKFLOW; + const apiUrl = `https://api.github.com/repos/${repository}/actions/workflows/${workflow}/dispatches`; + const response = await fetch(apiUrl, { + method: "POST", + headers: githubHeaders(env.RUNTIME_SETTINGS_DISPATCH_TOKEN), + body: JSON.stringify({ + ref: env.RUNTIME_SETTINGS_REF || "main", + inputs, + }), + }); + + if (!response.ok) { + const text = await response.text(); + return json({ ok: false, error: `GitHub dispatch failed: ${text.slice(0, 600)}` }, 502); + } + + return json({ + ok: true, + repository, + workflow, + actions_url: `https://github.com/${repository}/actions/workflows/${workflow}`, + inputs, + }); +} + +function normalizeSwitchInputs(raw) { + const platform = cleanChoice(raw.platform, SUPPORTED_PLATFORMS, "platform"); + const targetName = cleanSlug(raw.target_name, "target_name"); + const strategyProfile = cleanSlug(raw.strategy_profile, "strategy_profile").toLowerCase(); + const executionMode = cleanChoice(raw.execution_mode || "live", ["live", "paper"], "execution_mode"); + const pluginMode = cleanChoice(raw.plugin_mode || "auto", ["auto", "none", "custom"], "plugin_mode"); + const variableScope = cleanChoice( + raw.variable_scope || "default", + ["default", "repository", "environment"], + "variable_scope", + ); + const apply = cleanBoolean(raw.apply); + const triggerPlatformSync = cleanBoolean(raw.trigger_platform_sync) && apply; + const extraVariablesJson = cleanOptionalJsonObject(raw.extra_variables_json || "", "extra_variables_json"); + + const inputs = { + platform, + target_name: targetName, + strategy_profile: strategyProfile, + execution_mode: executionMode, + variable_scope: variableScope, + plugin_mode: pluginMode, + service_targets_mode: "auto", + apply: apply ? "true" : "false", + trigger_platform_sync: triggerPlatformSync ? "true" : "false", + confirm_apply: apply ? (triggerPlatformSync ? "APPLY_AND_SYNC" : "APPLY") : "", + platform_sync_workflow: "sync-cloud-run-env.yml", + }; + + addOptional(inputs, "github_environment", raw.github_environment, cleanSlug); + addOptional(inputs, "deployment_selector", raw.deployment_selector, cleanSlug); + addOptional(inputs, "account_selector", raw.account_selector, cleanCsv); + addOptional(inputs, "account_scope", raw.account_scope, cleanSlug); + addOptional(inputs, "service_name", raw.service_name, cleanSlug); + addOptional(inputs, "custom_plugin_mounts_json", raw.custom_plugin_mounts_json, cleanJson); + if (extraVariablesJson) inputs.extra_variables_json = extraVariablesJson; + return inputs; +} + +function assertSwitchIntent(inputs) { + if ( + inputs.apply !== "true" || + inputs.trigger_platform_sync !== "true" || + inputs.confirm_apply !== "APPLY_AND_SYNC" + ) { + throw new Error("switch endpoint requires apply=true and APPLY_AND_SYNC"); + } +} + +function assertConfiguredAccount(inputs, accountOptions) { + if (!accountOptions) throw new Error("private account options are not configured"); + const options = accountOptions[inputs.platform] || []; + if (!options.length) throw new Error(`no account options configured for ${inputs.platform}`); + const matched = options.some((option) => accountOptionMatchesInputs(option, inputs)); + if (!matched) throw new Error("switch inputs do not match configured account options"); +} + +function accountOptionMatchesInputs(option, inputs) { + if (option.target_name !== inputs.target_name) return false; + const fields = [ + "account_selector", + "deployment_selector", + "account_scope", + "service_name", + "github_environment", + "variable_scope", + "plugin_mode", + ]; + for (const field of fields) { + const expected = option[field] || ""; + const actual = inputs[field] || ""; + if (expected && actual !== expected) return false; + if (!expected && actual && !["default", "auto"].includes(actual)) return false; + } + return true; +} + +function parseAccountOptions(raw) { + const text = String(raw || "").trim(); + if (!text) return null; + let payload; + try { + payload = JSON.parse(text); + } catch (error) { + throw new Error("STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON must be valid JSON"); + } + if (!payload || Array.isArray(payload) || typeof payload !== "object") { + throw new Error("STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON must be an object"); + } + + const result = {}; + for (const platform of SUPPORTED_PLATFORMS) { + const items = payload[platform]; + if (items === undefined) continue; + if (!Array.isArray(items) || items.length > 20) { + throw new Error(`account options for ${platform} must be an array with at most 20 items`); + } + result[platform] = items.map((item, index) => cleanAccountOption(item, platform, index)); + } + return result; +} + +function cleanAccountOption(item, platform, index) { + if (!item || Array.isArray(item) || typeof item !== "object") { + throw new Error(`account option ${platform}[${index}] must be an object`); + } + const key = cleanSlug(item.key || item.target_name || `${platform}-${index}`, "account key"); + const label = cleanLabel(item.label || item.target_name || key, "account label"); + const option = { + key, + label, + target_name: cleanSlug(item.target_name || key, "target_name"), + }; + addConfigOptional(option, "account_selector", item.account_selector, cleanCsv); + addConfigOptional(option, "deployment_selector", item.deployment_selector, cleanSlug); + addConfigOptional(option, "account_scope", item.account_scope, cleanSlug); + addConfigOptional(option, "service_name", item.service_name, cleanSlug); + addConfigOptional(option, "github_environment", item.github_environment, cleanSlug); + addConfigOptional(option, "variable_scope", item.variable_scope, (value, field) => + cleanChoice(value || "default", ["default", "repository", "environment"], field), + ); + addConfigOptional(option, "plugin_mode", item.plugin_mode, (value, field) => + cleanChoice(value || "auto", ["auto", "none"], field), + ); + return option; +} + +function addConfigOptional(target, key, value, cleaner) { + if (value === undefined || value === null || String(value).trim() === "") return; + target[key] = cleaner(value, key); +} + +function addOptional(target, key, value, cleaner) { + if (value === undefined || value === null || String(value).trim() === "") return; + target[key] = cleaner(value, key); +} + +function cleanChoice(value, allowed, field) { + const text = String(value || "").trim(); + if (!allowed.includes(text)) throw new Error(`${field} is invalid`); + return text; +} + +function cleanBoolean(value) { + if (value === true || value === "true") return true; + if (value === false || value === "false" || value === "" || value === undefined || value === null) return false; + throw new Error("boolean input is invalid"); +} + +function cleanSlug(value, field) { + const text = String(value || "").trim(); + if (!text || text.length > 120 || !/^[A-Za-z0-9._=-]+$/.test(text)) { + throw new Error(`${field} must use letters, numbers, dot, underscore, dash, or equals`); + } + return text; +} + +function cleanLabel(value, field) { + const text = String(value || "").trim(); + if (!text || text.length > 80 || /[<>{}]/.test(text)) { + throw new Error(`${field} is invalid`); + } + return text; +} + +function requireSameOrigin(request) { + const origin = request.headers.get("Origin"); + if (!origin) return; + if (origin !== new URL(request.url).origin) throw new Error("cross-origin request rejected"); +} + +function cleanCsv(value, field) { + const text = String(value || "").trim(); + if (text.length > 300 || !/^[A-Za-z0-9._=,\-\s]+$/.test(text)) { + throw new Error(`${field} is invalid`); + } + return text; +} + +function cleanJson(value, field) { + const text = String(value || "").trim(); + if (!text) return ""; + if (text.length > 8000) throw new Error(`${field} is too long`); + JSON.parse(text); + return text; +} + +function cleanOptionalJsonObject(value, field) { + const text = cleanJson(value, field); + if (!text) return ""; + const payload = JSON.parse(text); + if (!payload || Array.isArray(payload) || typeof payload !== "object") { + throw new Error(`${field} must be a JSON object`); + } + for (const name of Object.keys(payload)) { + if (looksLikeSecretName(name)) { + throw new Error(`${field}.${name} looks like a secret and must not be stored here`); + } + } + return text; +} + +function looksLikeSecretName(name) { + const upperName = String(name || "").toUpperCase(); + if (/_SECRET_(ID|NAME|REF|RESOURCE|RESOURCE_NAME|VERSION)$/.test(upperName)) return false; + return /PASSWORD|PRIVATE_KEY|TOKEN|API_KEY|ACCESS_KEY|CLIENT_SECRET|SECRET/.test(upperName); +} + +function githubHeaders(token) { + return { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + "User-Agent": "QuantRuntimeSettings-StrategySwitchConsole", + "X-GitHub-Api-Version": "2022-11-28", + }; +} + +function requireEnv(env, name) { + if (!env[name]) throw new Error(`${name} is not configured`); +} + +function allowedLogins(env) { + return String(env.ALLOWED_GITHUB_LOGINS || env.ALLOWED_GITHUB_LOGIN || "") + .split(",") + .map((login) => login.trim().toLowerCase()) + .filter(Boolean); +} + +function adminLogins(env) { + return String(env.STRATEGY_SWITCH_ADMIN_LOGINS || "") + .split(",") + .map((login) => login.trim().toLowerCase()) + .filter(Boolean); +} + +function isAdminLogin(login, env) { + return adminLogins(env).includes(String(login || "").toLowerCase()); +} + +function isAllowedLogin(login, env) { + const normalized = String(login || "").toLowerCase(); + return allowedLogins(env).includes(normalized) || isAdminLogin(normalized, env); +} + +async function makeSession(login, env) { + const payload = base64UrlEncodeJson({ + login, + exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS, + }); + const signature = await hmac(payload, env.SESSION_SECRET); + return `${payload}.${signature}`; +} + +async function readSession(request, env) { + if (!env.SESSION_SECRET) return null; + const cookies = parseCookies(request.headers.get("Cookie") || ""); + const token = cookies[SESSION_COOKIE]; + if (!token || !token.includes(".")) return null; + const [payload, signature] = token.split(".", 2); + const expected = await hmac(payload, env.SESSION_SECRET); + if (signature !== expected) return null; + const session = JSON.parse(base64UrlDecode(payload)); + if (!session.exp || session.exp < Math.floor(Date.now() / 1000)) return null; + const login = String(session.login || "").toLowerCase(); + const admin = isAdminLogin(login, env); + return { login, allowed: admin || allowedLogins(env).includes(login), admin }; +} + +async function hmac(value, secret) { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)); + return base64UrlEncodeBytes(new Uint8Array(signature)); +} + +function randomToken() { + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + return base64UrlEncodeBytes(bytes); +} + +function base64UrlEncodeJson(value) { + return base64UrlEncodeBytes(new TextEncoder().encode(JSON.stringify(value))); +} + +function base64UrlEncodeBytes(bytes) { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +function base64UrlDecode(value) { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return new TextDecoder().decode(bytes); +} + +function parseCookies(header) { + const result = {}; + for (const part of header.split(";")) { + const [name, ...rest] = part.trim().split("="); + if (!name) continue; + result[name] = decodeURIComponent(rest.join("=")); + } + return result; +} + +function cookie(name, value, maxAge) { + return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`; +} + +function clearCookie(name) { + return `${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`; +} + +function clearOAuthCookie() { + return { "Set-Cookie": clearCookie(OAUTH_STATE_COOKIE) }; +} + +function json(payload, status = 200, headers = {}) { + return new Response(JSON.stringify(payload, null, 2), { + status, + headers: responseHeaders({ + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }, headers), + }); +} + +function html(body, status = 200, headers = {}) { + return new Response(body, { + status, + headers: responseHeaders({ + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, headers), + }); +} + +function redirect(location, headers = {}) { + return new Response(null, { + status: 302, + headers: responseHeaders({ Location: location }, headers), + }); +} + +function responseHeaders(base, extra) { + const headers = new Headers(base); + for (const [name, value] of Object.entries(extra)) { + if (Array.isArray(value)) { + for (const item of value) headers.append(name, item); + } else { + headers.set(name, value); + } + } + return headers; +} + +function renderMessage(title, message) { + return `${escapeHtml(title)} + +

${escapeHtml(title)}

${escapeHtml(message)}

返回控制台

`; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} diff --git a/web/strategy-switch-console/wrangler.toml.example b/web/strategy-switch-console/wrangler.toml.example new file mode 100644 index 0000000..669a34c --- /dev/null +++ b/web/strategy-switch-console/wrangler.toml.example @@ -0,0 +1,18 @@ +name = "qsl-strategy-switch-console" +main = "worker.js" +compatibility_date = "2026-06-09" +workers_dev = true + +# Set these with `wrangler secret put ...` instead of committing values: +# - GITHUB_CLIENT_ID +# - GITHUB_CLIENT_SECRET +# - SESSION_SECRET +# - RUNTIME_SETTINGS_DISPATCH_TOKEN +# - ALLOWED_GITHUB_LOGINS +# - STRATEGY_SWITCH_ADMIN_LOGINS +# - STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON +# +# Optional non-secret variables can be placed here or in the Cloudflare dashboard: +# RUNTIME_SETTINGS_REPO = "QuantStrategyLab/QuantRuntimeSettings" +# RUNTIME_SETTINGS_WORKFLOW = "manual-strategy-switch.yml" +# RUNTIME_SETTINGS_REF = "main" From e9898340822a1a2db873a00ac095497e58e7618c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 9 Jun 2026 02:18:32 +0800 Subject: [PATCH 2/2] Add OAuth admin management for strategy switch console --- docs/index.html | 32 +- docs/strategy_switch_admin_backend.md | 67 ++- docs/strategy_switch_admin_backend.zh-CN.md | 74 ++- web/strategy-switch-console/README.md | 42 +- web/strategy-switch-console/README.zh-CN.md | 46 +- .../account-options.example.json | 30 +- web/strategy-switch-console/page_asset.js | 2 +- web/strategy-switch-console/worker.js | 483 ++++++++++++++++-- .../wrangler.toml.example | 6 + 9 files changed, 639 insertions(+), 143 deletions(-) diff --git a/docs/index.html b/docs/index.html index 4462bcb..18e8383 100644 --- a/docs/index.html +++ b/docs/index.html @@ -618,7 +618,33 @@

切换摘要

{ key: "paper", label: "paper", target_name: "paper", account_selector: "PAPER" }, ], ibkr: [ - { key: "u0000000", label: "u0000000", target_name: "u0000000", account_selector: "u0000000" }, + { + key: "u15998061", + label: "u15998061", + target_name: "u15998061", + account_selector: "U15998061", + deployment_selector: "live-u1599-tqqq", + account_scope: "live-u1599-tqqq", + service_name: "interactive-brokers-live-u1599-tqqq-service", + }, + { + key: "u16608560", + label: "u16608560", + target_name: "u16608560", + account_selector: "U16608560", + deployment_selector: "live-u1660-soxl", + account_scope: "live-u1660-soxl", + service_name: "interactive-brokers-live-u1660-soxl-service", + }, + { + key: "u18336562", + label: "u18336562", + target_name: "u18336562", + account_selector: "U18336562", + deployment_selector: "live-u1833-smart-dca", + account_scope: "live-u1833-smart-dca", + service_name: "interactive-brokers-live-u1833-smart-dca-service", + }, ], schwab: [ { key: "default", label: "default", target_name: "default" }, @@ -669,7 +695,7 @@

切换摘要

en: { appTitle: "Strategy Switch", appSubtitle: "Pick platform, target account, and strategy. One action switches everything.", - loginManage: "Login", + loginManage: "Login Management", activePlatform: "Active Platform", account: "Target account", strategy: "Strategy", @@ -719,7 +745,7 @@

切换摘要

configSource: "default", forms: { longbridge: { accountKey: "hk", strategy: "tqqq_growth_income", executionMode: "live" }, - ibkr: { accountKey: "u0000000", strategy: "tqqq_growth_income", executionMode: "live" }, + ibkr: { accountKey: "u15998061", strategy: "tqqq_growth_income", executionMode: "live" }, schwab: { accountKey: "default", strategy: "tqqq_growth_income", executionMode: "live" }, firstrade: { accountKey: "default", strategy: "tqqq_growth_income", executionMode: "live" }, }, diff --git a/docs/strategy_switch_admin_backend.md b/docs/strategy_switch_admin_backend.md index c6699de..5610a34 100644 --- a/docs/strategy_switch_admin_backend.md +++ b/docs/strategy_switch_admin_backend.md @@ -1,55 +1,50 @@ # Strategy Switch Admin Backend -Goal: keep the personal strategy switch console simple while avoiding code changes for every login or account dropdown update. +Goal: keep the open-source switch page public and read-only by default, while allowing an authenticated admin to manage who can switch strategies and which account routes appear in the dropdown. -## Current Mode +## Current Implementation -- GitHub OAuth signs users in. -- `ALLOWED_GITHUB_LOGINS` controls who can dispatch a switch. -- `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON` controls signed-in account dropdowns. -- The GitHub dispatch token stays in Worker secrets and is never sent to the browser. +- Login method: GitHub OAuth 2.0. +- Public access: unsigned visitors can view the page, but cannot dispatch the workflow. +- Allowed switch users: `ALLOWED_GITHUB_LOGINS`, KV `auth_config.allowed_logins`, and all admins. +- Admin users: `STRATEGY_SWITCH_ADMIN_LOGINS` plus KV `auth_config.admin_logins`. +- Account dropdowns: KV `account_options` first, falling back to `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`. +- Audit log: each admin save appends to KV `audit_log`, capped at 50 entries. -This is enough for the first deployment. Its main limitation is that user and account changes require updating Worker secrets. +## Cloudflare KV -## Recommended Admin Mode +Bind the namespace: -Keep GitHub OAuth and use an admin-only `/admin` page: +```toml +[[kv_namespaces]] +binding = "STRATEGY_SWITCH_CONFIG" +id = "..." +``` -- Bootstrap admins come from `STRATEGY_SWITCH_ADMIN_LOGINS`; keep your own GitHub login there. -- Admin actions: - - The current version verifies admin identity and shows configured account counts for the four platforms. - - After KV is connected, add or remove allowed GitHub logins. - - After KV is connected, edit account dropdowns for the four platforms. - - After KV is connected, review recent permission and account-config changes. -- Storage: - - Cloudflare KV namespace: `STRATEGY_SWITCH_CONFIG`. - - key `auth_config`: `allowed_logins` and `admin_logins`. - - key `account_options`: platform account dropdowns. - - key `audit_log`: recent admin changes. +KV keys: + +```text +auth_config +account_options +audit_log +``` + +Without the KV binding, `/admin` is read-only and the Worker falls back to secrets. ## Permission Rules - Not signed in: public read-only page. -- Signed in but not allowlisted: no switch, no admin page. +- Signed in but not allowlisted: no switch and no admin page. - Allowlisted: can dispatch switches. -- Admin-listed: can manage login permissions and account dropdowns. -- `STRATEGY_SWITCH_ADMIN_LOGINS` remains the break-glass admin source so you cannot remove yourself through the UI. +- Admin-listed: can open `/admin` and manage allowed logins, admin logins, and account dropdown JSON. +- `STRATEGY_SWITCH_ADMIN_LOGINS` remains the break-glass admin source and is preserved on save. ## Security Boundary - The admin backend stores GitHub logins and account routing metadata only. - Broker passwords, tokens, API keys, and cloud credentials stay out of this config. -- Admin writes use POST and the existing Worker same-origin checks. -- Sessions keep HttpOnly, Secure, SameSite=Lax, and HMAC-signed cookies. -- Dispatch tokens remain separate from admin config and are never readable from frontend code. -- Audit logs record time, admin login, and action type, but never secrets. - -## Rollout - -1. Ship the current secret-backed console. -2. The read-only `/admin` verification page is already available for `STRATEGY_SWITCH_ADMIN_LOGINS`. -3. Add Worker KV reads with secret fallback. -4. Add `/api/admin/config` write operations for admins. -5. Add audit logs and last-version rollback. +- Admin writes use POST and same-origin checks. +- Sessions use HttpOnly, Secure, SameSite=Lax, and HMAC-signed cookies. +- The GitHub dispatch token stays in Worker secrets and is never returned to frontend or admin APIs. -This avoids a database, custom user system, or broad RBAC while still giving a practical backend for a personal open-source project. +This keeps the personal system simple: no database, review flow, or custom RBAC, while preventing strangers from operating the public page. diff --git a/docs/strategy_switch_admin_backend.zh-CN.md b/docs/strategy_switch_admin_backend.zh-CN.md index f8d7d6c..8735b03 100644 --- a/docs/strategy_switch_admin_backend.zh-CN.md +++ b/docs/strategy_switch_admin_backend.zh-CN.md @@ -1,54 +1,50 @@ -# 策略切换登录权限后台方案 +# 策略切换登录权限后台 -目标:保持个人量化系统足够简单,同时不用每次改权限或账号下拉都重新改代码。 +目标:开源页面可以公开查看,但只有登录并通过权限校验的 GitHub 账号能一键切换策略;管理员可以自己维护登录名单和账号下拉,不需要每次改代码。 -## 当前模式 +## 当前实现 -- GitHub OAuth 登录。 -- `ALLOWED_GITHUB_LOGINS` 控制谁能触发切换。 -- `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON` 控制登录后可选账号。 -- GitHub dispatch token 只在 Worker secret 里,前端拿不到。 +- 登录方式:GitHub OAuth 2.0。 +- 公开访问:未登录用户只能看到只读切换页,不能触发 workflow。 +- 可切换用户:来自 `ALLOWED_GITHUB_LOGINS`、KV `auth_config.allowed_logins` 和管理员名单。 +- 管理员:来自 `STRATEGY_SWITCH_ADMIN_LOGINS` 和 KV `auth_config.admin_logins`。 +- 账号下拉:优先读取 KV `account_options`,没有 KV 配置时回退 `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`。 +- 审计:管理员保存配置后写入 KV `audit_log`,保留最近 50 条。 -这个模式可以先上线。它的缺点是:新增登录用户或账号 target 时,需要改 Worker secret。 +## Cloudflare KV -## 推荐后台模式 +绑定 namespace: -保留 GitHub OAuth,使用一个只给管理员看的 `/admin` 页面: +```toml +[[kv_namespaces]] +binding = "STRATEGY_SWITCH_CONFIG" +id = "..." +``` -- 管理员:由 `STRATEGY_SWITCH_ADMIN_LOGINS` 启动配置,建议只放你自己的 GitHub login。 -- 可操作内容: - - 当前已支持验证管理员身份,并展示四个平台已加载账号数量。 - - 后续接 KV 后,可添加或移除允许登录的 GitHub 用户名。 - - 后续接 KV 后,可编辑四个平台的账号下拉配置。 - - 后续接 KV 后,可查看最近的权限和账号配置修改记录。 -- 存储: - - Cloudflare KV namespace:`STRATEGY_SWITCH_CONFIG`。 - - key `auth_config`:保存 `allowed_logins`、`admin_logins`。 - - key `account_options`:保存四个平台账号下拉。 - - key `audit_log`:保存最近 50 条管理操作。 +使用的 key: + +```text +auth_config +account_options +audit_log +``` + +没有绑定 KV 时,`/admin` 只读;登录和切换仍可通过 Worker secrets 运行。 ## 权限规则 -- 未登录:只能看公开只读页。 +- 未登录:只能查看公开页面。 - 登录但不在 allowlist:不能切换,也不能进入后台。 -- 登录且在 allowlist:可以一键切换。 -- 登录且在 admin list:可以进入后台管理权限和账号配置。 -- `STRATEGY_SWITCH_ADMIN_LOGINS` 是兜底管理员,不通过后台删除,避免把自己锁在外面。 +- allowlist 用户:可以一键切换。 +- admin 用户:可以进入 `/admin`,维护 allowlist、admin list 和账号下拉 JSON。 +- `STRATEGY_SWITCH_ADMIN_LOGINS` 是兜底管理员来源,后台保存时会自动保留,避免把自己锁在外面。 ## 安全边界 -- 后台只管理 GitHub login 和账号路由信息,不保存 broker 密码、token、API key。 -- 所有后台写操作使用 POST,并复用当前 Worker 的 Same-Origin 校验。 -- session cookie 继续使用 HttpOnly、Secure、SameSite=Lax 和 HMAC 签名。 -- dispatch token 与后台配置分离;管理员能改账号路由,但不能在前端读取 token。 -- 每次后台修改写 audit log,至少记录时间、管理员 login、操作类型,不记录密钥。 - -## 分阶段落地 - -1. 先上线当前 secret 模式:页面一键切换、账号配置由 `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON` 提供。 -2. `/admin` 只读验证页已经具备:可验证 `STRATEGY_SWITCH_ADMIN_LOGINS` 管理权限,并展示已加载账号数量。 -3. 给 Worker 增加 KV 读取:优先读 KV,没有 KV 时回退到 secrets。 -4. 增加 `/api/admin/config` 写接口:管理员可更新 allowlist 和账号配置。 -5. 增加 audit log 与回滚:保留最近版本,改错后可以恢复。 +- 后台只保存 GitHub login 和账号路由信息。 +- 不保存 broker 密码、token、API key 或云密钥。 +- 后台写操作使用 POST,并校验 Same-Origin。 +- session cookie 使用 HttpOnly、Secure、SameSite=Lax 和 HMAC 签名。 +- GitHub dispatch token 只在 Worker secret 中,前端和后台配置接口都不会返回。 -这个方案不引入独立数据库、用户系统或复杂 RBAC,适合个人开源项目。真正的权限根仍然是你的 GitHub 账号和 Worker secret。 +这个方案保留个人项目的简单性:没有独立数据库、审批流或复杂 RBAC,但公开页面不能被陌生人直接操作。 diff --git a/web/strategy-switch-console/README.md b/web/strategy-switch-console/README.md index ca69e25..e16a845 100644 --- a/web/strategy-switch-console/README.md +++ b/web/strategy-switch-console/README.md @@ -23,7 +23,7 @@ Optional variables: RUNTIME_SETTINGS_REPO=QuantStrategyLab/QuantRuntimeSettings RUNTIME_SETTINGS_WORKFLOW=manual-strategy-switch.yml RUNTIME_SETTINGS_REF=main -STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON={"longbridge":[{"key":"hk","label":"hk","target_name":"hk","account_selector":"HK"},{"key":"sg","label":"sg","target_name":"sg","account_selector":"SG"},{"key":"paper","label":"paper","target_name":"paper","account_selector":"PAPER"}],"ibkr":[{"key":"u0000000","label":"u0000000","target_name":"u0000000","account_selector":"u0000000"}],"schwab":[{"key":"default","label":"default","target_name":"default"}],"firstrade":[{"key":"default","label":"default","target_name":"default"}]} +STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON= ``` `ALLOWED_GITHUB_LOGINS` and `STRATEGY_SWITCH_ADMIN_LOGINS` are comma-separated lists: @@ -32,7 +32,7 @@ STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON={"longbridge":[{"key":"hk","label":"hk","ta your-github-login ``` -The login entrypoint is `/login` on the Worker domain. When the Worker is available, the page header shows the GitHub sign-in link. After sign-in, `/api/session` returns: +The login entrypoint is `/login` on the Worker domain. The page header keeps a single Login Management entry. After sign-in, `/api/session` returns: ```json { @@ -43,7 +43,21 @@ The login entrypoint is `/login` on the Worker domain. When the Worker is availa } ``` -`admin=true` means the login is listed in `STRATEGY_SWITCH_ADMIN_LOGINS`. You can also open `/admin` directly to verify admin permission; non-admin users receive 403. +`admin=true` means the login is listed in `STRATEGY_SWITCH_ADMIN_LOGINS` or the KV-backed admin list. Open `/admin` to manage allowed GitHub logins and account dropdown routes; non-admin users receive 403. + +## Admin Management + +GitHub OAuth 2.0 is the only login method. Keep your own GitHub login in `STRATEGY_SWITCH_ADMIN_LOGINS`; that secret is the break-glass admin source and cannot be removed from the UI. + +For editable admin settings, bind a Cloudflare KV namespace named `STRATEGY_SWITCH_CONFIG`. The Worker uses these KV keys: + +```text +auth_config +account_options +audit_log +``` + +Without the KV binding, `/admin` is read-only and the Worker falls back to `ALLOWED_GITHUB_LOGINS`, `STRATEGY_SWITCH_ADMIN_LOGINS`, and `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`. ## Page Asset @@ -72,15 +86,19 @@ cd web/strategy-switch-console wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-accounts.json ``` +After `STRATEGY_SWITCH_CONFIG` is bound, admins can also edit and save the same account JSON from `/admin`. KV takes precedence over the secret; the secret remains a fallback. + Each account item supports: ```json { - "key": "u0000000", - "label": "u0000000", - "target_name": "u0000000", - "account_selector": "u0000000", - "service_name": "interactive-brokers-u0000000-service" + "key": "u15998061", + "label": "u15998061", + "target_name": "u15998061", + "account_selector": "U15998061", + "deployment_selector": "live-u1599-tqqq", + "account_scope": "live-u1599-tqqq", + "service_name": "interactive-brokers-live-u1599-tqqq-service" } ``` @@ -116,6 +134,14 @@ wrangler secret put STRATEGY_SWITCH_ADMIN_LOGINS wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-accounts.json ``` +Create and bind KV if you want `/admin` to save changes: + +```bash +wrangler kv namespace create STRATEGY_SWITCH_CONFIG +``` + +Add the returned namespace id to `wrangler.toml`. + Deploy: ```bash diff --git a/web/strategy-switch-console/README.zh-CN.md b/web/strategy-switch-console/README.zh-CN.md index a4a26c2..b9c03be 100644 --- a/web/strategy-switch-console/README.zh-CN.md +++ b/web/strategy-switch-console/README.zh-CN.md @@ -25,7 +25,7 @@ STRATEGY_SWITCH_ADMIN_LOGINS RUNTIME_SETTINGS_REPO=QuantStrategyLab/QuantRuntimeSettings RUNTIME_SETTINGS_WORKFLOW=manual-strategy-switch.yml RUNTIME_SETTINGS_REF=main -STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON={"longbridge":[{"key":"hk","label":"hk","target_name":"hk","account_selector":"HK"},{"key":"sg","label":"sg","target_name":"sg","account_selector":"SG"},{"key":"paper","label":"paper","target_name":"paper","account_selector":"PAPER"}],"ibkr":[{"key":"u0000000","label":"u0000000","target_name":"u0000000","account_selector":"u0000000"}],"schwab":[{"key":"default","label":"default","target_name":"default"}],"firstrade":[{"key":"default","label":"default","target_name":"default"}]} +STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON= ``` `ALLOWED_GITHUB_LOGINS` 和 `STRATEGY_SWITCH_ADMIN_LOGINS` 用英文逗号分隔,例如: @@ -34,7 +34,7 @@ STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON={"longbridge":[{"key":"hk","label":"hk","ta your-github-login ``` -登录入口是 Worker 域名下的 `/login`,页面顶部会在 Worker 可用时显示“登录 GitHub”。登录成功后访问 `/api/session` 会返回: +登录入口是 Worker 域名下的 `/login`,页面顶部保留一个“登录管理”入口。登录成功后访问 `/api/session` 会返回: ```json { @@ -45,7 +45,21 @@ your-github-login } ``` -`admin=true` 表示该账号在 `STRATEGY_SWITCH_ADMIN_LOGINS` 中。也可以直接访问 `/admin` 验证管理权限;非管理员会返回 403。 +`admin=true` 表示该账号在 `STRATEGY_SWITCH_ADMIN_LOGINS` 或 KV 后台管理员名单中。直接访问 `/admin` 可以管理允许登录的 GitHub 用户和账号下拉路由;非管理员会返回 403。 + +## 登录管理后台 + +登录方式使用 GitHub OAuth 2.0。建议把你自己的 GitHub login 放在 `STRATEGY_SWITCH_ADMIN_LOGINS`,它是兜底管理员来源,后台里不能把这个入口删掉。 + +如果要让 `/admin` 保存修改,需要绑定 Cloudflare KV namespace:`STRATEGY_SWITCH_CONFIG`。Worker 会使用这些 key: + +```text +auth_config +account_options +audit_log +``` + +没有绑定 KV 时,`/admin` 只读;Worker 会回退读取 `ALLOWED_GITHUB_LOGINS`、`STRATEGY_SWITCH_ADMIN_LOGINS` 和 `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`。 ## 文件结构 @@ -78,15 +92,19 @@ cd web/strategy-switch-console wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-accounts.json ``` +绑定 `STRATEGY_SWITCH_CONFIG` 后,也可以直接在 `/admin` 编辑并保存同一份账号 JSON。KV 优先级高于 secret;secret 作为兜底配置。 + 每个账号项支持这些字段: ```json { - "key": "u0000000", - "label": "u0000000", - "target_name": "u0000000", - "account_selector": "u0000000", - "service_name": "interactive-brokers-u0000000-service" + "key": "u15998061", + "label": "u15998061", + "target_name": "u15998061", + "account_selector": "U15998061", + "deployment_selector": "live-u1599-tqqq", + "account_scope": "live-u1599-tqqq", + "service_name": "interactive-brokers-live-u1599-tqqq-service" } ``` @@ -122,6 +140,14 @@ wrangler secret put STRATEGY_SWITCH_ADMIN_LOGINS wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-accounts.json ``` +如果要启用后台保存,先创建 KV: + +```bash +wrangler kv namespace create STRATEGY_SWITCH_CONFIG +``` + +然后把返回的 namespace id 加到 `wrangler.toml`。 + 部署: ```bash @@ -138,9 +164,9 @@ wrangler deploy 1. 访问控制台页面。 2. 未登录时只能查看公开示例,“一键切换”按钮禁用。 -3. 点击“登录 GitHub”,也可以直接访问 `/login`。 +3. 点击“登录管理”,也可以直接访问 `/login`。 4. 如果登录账号在 `ALLOWED_GITHUB_LOGINS` 或 `STRATEGY_SWITCH_ADMIN_LOGINS`,且账号配置已加载,按钮启用。 -5. 顶部只保留“登录管理”入口;如果登录账号在 `STRATEGY_SWITCH_ADMIN_LOGINS`,点击后进入 `/admin` 验证管理权限。 +5. 顶部只保留“登录管理”入口;如果登录账号是管理员,点击后进入 `/admin` 管理登录权限和账号下拉。 6. 选择平台、账号、策略和模式后点击“一键切换”。 7. 页面返回 GitHub Actions 链接,用于查看运行结果。 diff --git a/web/strategy-switch-console/account-options.example.json b/web/strategy-switch-console/account-options.example.json index ce21ea8..87cf1a2 100644 --- a/web/strategy-switch-console/account-options.example.json +++ b/web/strategy-switch-console/account-options.example.json @@ -21,11 +21,31 @@ ], "ibkr": [ { - "key": "u0000000", - "label": "u0000000", - "target_name": "u0000000", - "account_selector": "u0000000", - "service_name": "interactive-brokers-u0000000-service" + "key": "u15998061", + "label": "u15998061", + "target_name": "u15998061", + "account_selector": "U15998061", + "deployment_selector": "live-u1599-tqqq", + "account_scope": "live-u1599-tqqq", + "service_name": "interactive-brokers-live-u1599-tqqq-service" + }, + { + "key": "u16608560", + "label": "u16608560", + "target_name": "u16608560", + "account_selector": "U16608560", + "deployment_selector": "live-u1660-soxl", + "account_scope": "live-u1660-soxl", + "service_name": "interactive-brokers-live-u1660-soxl-service" + }, + { + "key": "u18336562", + "label": "u18336562", + "target_name": "u18336562", + "account_selector": "U18336562", + "deployment_selector": "live-u1833-smart-dca", + "account_scope": "live-u1833-smart-dca", + "service_name": "interactive-brokers-live-u1833-smart-dca-service" } ], "schwab": [ diff --git a/web/strategy-switch-console/page_asset.js b/web/strategy-switch-console/page_asset.js index 5d500b4..7c58fab 100644 --- a/web/strategy-switch-console/page_asset.js +++ b/web/strategy-switch-console/page_asset.js @@ -1,2 +1,2 @@ // Generated by scripts/sync_strategy_switch_page_asset.py; do not edit by hand. -export const PAGE_HTML = "\n\n\n \n \n \n QuantRuntimeSettings Strategy Switch\n \n\n\n
\n
\n

策略切换

\n

选平台、目标账号和策略,一次执行完成切换。

\n
\n \n
\n\n
\n \n\n
\n
\n
\n 当前平台\n

LongBridge

\n
\n\n
\n \n\n \n\n
\n 模式\n
\n \n \n
\n
\n
\n\n
\n \n

公开页面只能查看;登录版校验账号权限后才会触发 workflow。

\n

\n
\n
\n\n \n
\n
\n\n \n\n\n"; +export const PAGE_HTML = "\n\n\n \n \n \n QuantRuntimeSettings Strategy Switch\n \n\n\n
\n
\n

策略切换

\n

选平台、目标账号和策略,一次执行完成切换。

\n
\n \n
\n\n
\n \n\n
\n
\n
\n 当前平台\n

LongBridge

\n
\n\n
\n \n\n \n\n
\n 模式\n
\n \n \n
\n
\n
\n\n
\n \n

公开页面只能查看;登录版校验账号权限后才会触发 workflow。

\n

\n
\n
\n\n \n
\n
\n\n \n\n\n"; diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 7eef666..6c5fc4a 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -5,6 +5,10 @@ const DEFAULT_WORKFLOW = "manual-strategy-switch.yml"; const SESSION_COOKIE = "qsl_switch_session"; const OAUTH_STATE_COOKIE = "qsl_switch_oauth_state"; const SESSION_TTL_SECONDS = 8 * 60 * 60; +const AUTH_CONFIG_KEY = "auth_config"; +const ACCOUNT_OPTIONS_KEY = "account_options"; +const AUDIT_LOG_KEY = "audit_log"; +const AUDIT_LOG_LIMIT = 50; const SUPPORTED_PLATFORMS = ["longbridge", "ibkr", "schwab", "firstrade"]; @@ -17,6 +21,10 @@ export default { if (url.pathname === "/admin") return adminPage(request, env); if (url.pathname === "/api/session") return json(await sessionPayload(request, env)); if (url.pathname === "/api/config") return json(await configPayload(request, env)); + if (url.pathname === "/api/admin/config" && request.method === "GET") return adminConfigResponse(request, env); + if (url.pathname === "/api/admin/config" && request.method === "POST") { + return saveAdminConfig(request, env); + } if (url.pathname === "/api/logout" && request.method === "POST") return logout(request); if (url.pathname === "/api/switch" && request.method === "POST") return dispatchSwitch(request, env); return html(PAGE_HTML); @@ -80,7 +88,8 @@ async function finishLogin(request, env) { return html(renderMessage("登录失败", "无法读取 GitHub 用户。"), 502, clearOAuthCookie()); } - if (!isAllowedLogin(login, env)) { + const authConfig = await loadAuthConfig(env); + if (!isAllowedLogin(login, authConfig)) { return html(renderMessage("没有权限", `${login} 不在允许登录名单中。`), 403, clearOAuthCookie()); } @@ -104,38 +113,287 @@ async function sessionPayload(request, env) { } async function adminPage(request, env) { + const session = await requireAdminSession(request, env); + if (session instanceof Response) return session; + return html(await renderAdminPage(await buildAdminState(session, env))); +} + +async function adminConfigResponse(request, env) { + const session = await readSession(request, env); + if (!session) return json({ ok: false, error: "login required" }, 401); + if (!session.admin) return json({ ok: false, error: "admin required" }, 403); + return json(await buildAdminState(session, env)); +} + +async function saveAdminConfig(request, env) { + requireSameOrigin(request); + const session = await readSession(request, env); + if (!session) return json({ ok: false, error: "login required" }, 401); + if (!session.admin) return json({ ok: false, error: "admin required" }, 403); + if (!hasConfigStore(env)) { + return json({ ok: false, error: "STRATEGY_SWITCH_CONFIG KV binding is required to save admin config" }, 400); + } + + let raw; + try { + raw = await request.json(); + } catch (error) { + return json({ ok: false, error: "request body must be valid JSON" }, 400); + } + const bootstrapAdmins = parseLoginList(env.STRATEGY_SWITCH_ADMIN_LOGINS || "", "STRATEGY_SWITCH_ADMIN_LOGINS"); + const allowedLogins = normalizeLoginList(raw.allowed_logins, "allowed_logins"); + const submittedAdmins = normalizeLoginList(raw.admin_logins, "admin_logins"); + const effectiveAdmins = uniqueStrings([...bootstrapAdmins, ...submittedAdmins]); + if (!effectiveAdmins.includes(session.login)) { + throw new Error("current admin login must remain in admin_logins"); + } + const authConfig = { + allowed_logins: uniqueStrings([...allowedLogins, ...effectiveAdmins]), + admin_logins: effectiveAdmins, + }; + const accountOptions = normalizeAccountOptionsInput(raw.account_options, "account_options"); + + await writeConfigJson(env, AUTH_CONFIG_KEY, authConfig); + await writeConfigJson(env, ACCOUNT_OPTIONS_KEY, accountOptions); + await appendAuditLog(env, { + ts: new Date().toISOString(), + login: session.login, + action: "save_config", + allowed_count: authConfig.allowed_logins.length, + admin_count: authConfig.admin_logins.length, + account_counts: accountCounts(accountOptions), + }); + return json(await buildAdminState(session, env)); +} + +async function requireAdminSession(request, env) { const session = await readSession(request, env); if (!session) return redirect("/login"); if (!session.admin) { - return html(renderMessage("没有管理权限", `${session.login} 不在 STRATEGY_SWITCH_ADMIN_LOGINS 中。`), 403); + return html(renderMessage("没有管理权限", `${session.login} 不在管理员名单中。`), 403); } + return session; +} + +async function buildAdminState(session, env) { + const authConfig = await loadAuthConfig(env); + const accountConfig = await loadAccountOptionsConfig(env); + return { + ok: true, + session: { login: session.login, admin: true }, + kvAvailable: hasConfigStore(env), + authConfig, + accountOptions: accountConfig.options || {}, + accountOptionSource: accountConfig.source, + auditLog: await loadAuditLog(env), + }; +} - const configuredPlatforms = parseAccountOptions(env.STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON || "") || {}; +async function renderAdminPage(state) { + const disabled = state.kvAvailable ? "" : " disabled"; + const statusClass = state.kvAvailable ? "ready" : "warn"; + const statusText = state.kvAvailable ? "KV 已连接 / KV connected" : "KV 未绑定,只读 / Read-only"; + const sourceText = state.accountOptionSource === "kv" + ? "KV" + : (state.accountOptionSource === "secret" ? "Worker secret" : "none"); const accountRows = SUPPORTED_PLATFORMS.map((platform) => { - const count = Array.isArray(configuredPlatforms[platform]) ? configuredPlatforms[platform].length : 0; + const count = Array.isArray(state.accountOptions[platform]) ? state.accountOptions[platform].length : 0; return `${escapeHtml(platform)}${count}`; }).join(""); - return html(` - - -Strategy Switch Admin - -
-

Strategy Switch Admin

-

Signed in as ${escapeHtml(session.login)}. Admin permission is verified by STRATEGY_SWITCH_ADMIN_LOGINS.

-

Account options

-${accountRows}
PlatformConfigured accounts
-

Next step: connect Cloudflare KV to edit allowlist and account options here. Current version verifies admin access and shows loaded private account config counts.

-

Back to switch console

-
-`); + const auditRows = state.auditLog.length + ? state.auditLog.map((entry) => ( + `${escapeHtml(entry.ts || "")}${escapeHtml(entry.login || "")}${escapeHtml(entry.action || "")}` + )).join("") + : `暂无记录 / No records`; + return ` + + + + + + Strategy Switch Login Management + + + +
+
+

登录管理 / Login Management

+

GitHub OAuth 2.0 管理策略切换权限。

+
+
+ 返回切换页 + +
+
+
+
+
+ ${escapeHtml(state.session.login)} + 当前管理员 / Current admin +
+
+ ${escapeHtml(statusText)} + 保存后台配置需要 Cloudflare KV。 +
+
+ ${escapeHtml(sourceText)} + 账号配置来源 / Account source +
+
+
+
+

登录权限 / Login Access

+

每行一个 GitHub 用户名。管理员会自动拥有切换权限;secret 里的管理员始终保留为兜底入口。

+
+ + +
+
+
+

账号下拉 / Account Options

+

这里只保存账号路由,不保存 broker 密码、token、API key 或云密钥。

+ +
+
+ + ${state.kvAvailable ? "" : "当前未绑定 STRATEGY_SWITCH_CONFIG KV,只能查看。"} +
+
+
+

账号数量 / Account Counts

+ + + ${accountRows} +
PlatformAccounts
+
+
+

最近修改 / Recent Changes

+ + + ${auditRows} +
TimeLoginAction
+
+
+ + +`; } async function configPayload(request, env) { const session = await readSession(request, env); if (!session?.allowed) return { accountOptions: null }; + const accountConfig = await loadAccountOptionsConfig(env); return { - accountOptions: parseAccountOptions(env.STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON || ""), + accountOptions: accountConfig.options, }; } @@ -155,7 +413,8 @@ async function dispatchSwitch(request, env) { const rawInput = await request.json(); const inputs = normalizeSwitchInputs(rawInput); assertSwitchIntent(inputs); - assertConfiguredAccount(inputs, parseAccountOptions(env.STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON || "")); + const accountConfig = await loadAccountOptionsConfig(env); + assertConfiguredAccount(inputs, accountConfig.options); const repository = env.RUNTIME_SETTINGS_REPO || DEFAULT_REPOSITORY; const workflow = env.RUNTIME_SETTINGS_WORKFLOW || DEFAULT_WORKFLOW; const apiUrl = `https://api.github.com/repos/${repository}/actions/workflows/${workflow}/dispatches`; @@ -259,17 +518,26 @@ function accountOptionMatchesInputs(option, inputs) { return true; } -function parseAccountOptions(raw) { +function parseAccountOptions(raw, fieldName = "account options") { const text = String(raw || "").trim(); if (!text) return null; let payload; try { payload = JSON.parse(text); } catch (error) { - throw new Error("STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON must be valid JSON"); + throw new Error(`${fieldName} must be valid JSON`); } + return normalizeAccountOptionsPayload(payload, fieldName); +} + +function normalizeAccountOptionsInput(value, fieldName) { + if (typeof value === "string") return parseAccountOptions(value, fieldName) || {}; + return normalizeAccountOptionsPayload(value, fieldName); +} + +function normalizeAccountOptionsPayload(payload, fieldName = "account options") { if (!payload || Array.isArray(payload) || typeof payload !== "object") { - throw new Error("STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON must be an object"); + throw new Error(`${fieldName} must be an object`); } const result = {}; @@ -277,7 +545,7 @@ function parseAccountOptions(raw) { const items = payload[platform]; if (items === undefined) continue; if (!Array.isArray(items) || items.length > 20) { - throw new Error(`account options for ${platform} must be an array with at most 20 items`); + throw new Error(`${fieldName}.${platform} must be an array with at most 20 items`); } result[platform] = items.map((item, index) => cleanAccountOption(item, platform, index)); } @@ -404,27 +672,159 @@ function requireEnv(env, name) { if (!env[name]) throw new Error(`${name} is not configured`); } -function allowedLogins(env) { - return String(env.ALLOWED_GITHUB_LOGINS || env.ALLOWED_GITHUB_LOGIN || "") - .split(",") - .map((login) => login.trim().toLowerCase()) - .filter(Boolean); +async function loadAuthConfig(env) { + const bootstrapAdmins = parseLoginList(env.STRATEGY_SWITCH_ADMIN_LOGINS || "", "STRATEGY_SWITCH_ADMIN_LOGINS"); + const envAllowed = parseLoginList( + env.ALLOWED_GITHUB_LOGINS || env.ALLOWED_GITHUB_LOGIN || "", + "ALLOWED_GITHUB_LOGINS", + ); + let storedAllowed = []; + let storedAdmins = []; + let source = "secret"; + if (hasConfigStore(env)) { + const stored = await readConfigJson(env, AUTH_CONFIG_KEY); + if (stored) { + const normalized = normalizeAuthConfigPayload(stored, AUTH_CONFIG_KEY); + storedAllowed = normalized.allowed_logins; + storedAdmins = normalized.admin_logins; + source = "kv"; + } + } + const adminLogins = uniqueStrings([...bootstrapAdmins, ...storedAdmins]); + const allowedLogins = uniqueStrings([...envAllowed, ...storedAllowed, ...adminLogins]); + return { + allowed_logins: allowedLogins, + admin_logins: adminLogins, + bootstrap_admin_logins: bootstrapAdmins, + env_allowed_logins: envAllowed, + source, + kv_available: hasConfigStore(env), + }; +} + +function normalizeAuthConfigPayload(payload, fieldName) { + if (!payload || Array.isArray(payload) || typeof payload !== "object") { + throw new Error(`${fieldName} must be an object`); + } + return { + allowed_logins: normalizeLoginList(payload.allowed_logins || [], `${fieldName}.allowed_logins`), + admin_logins: normalizeLoginList(payload.admin_logins || [], `${fieldName}.admin_logins`), + }; +} + +function parseLoginList(value, fieldName) { + return normalizeLoginList(value, fieldName); +} + +function normalizeLoginList(value, fieldName) { + const items = Array.isArray(value) ? value : String(value || "").split(/[\s,]+/); + if (items.length > 80) throw new Error(`${fieldName} supports at most 80 logins`); + return uniqueStrings(items.map((item) => cleanGithubLogin(item, fieldName)).filter(Boolean)); +} + +function cleanGithubLogin(value, fieldName) { + const login = String(value || "").trim().toLowerCase(); + if (!login) return ""; + if ( + login.length > 39 || + !/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(login) || + login.includes("--") + ) { + throw new Error(`${fieldName} contains an invalid GitHub login`); + } + return login; +} + +function isAdminLogin(login, authConfig) { + return authConfig.admin_logins.includes(String(login || "").toLowerCase()); +} + +function isAllowedLogin(login, authConfig) { + return authConfig.allowed_logins.includes(String(login || "").toLowerCase()) || isAdminLogin(login, authConfig); +} + +async function loadAccountOptionsConfig(env) { + if (hasConfigStore(env)) { + const stored = await readConfigJson(env, ACCOUNT_OPTIONS_KEY); + if (stored) { + return { + options: normalizeAccountOptionsPayload(stored, ACCOUNT_OPTIONS_KEY), + source: "kv", + }; + } + } + return { + options: parseAccountOptions(env.STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON || "", "STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON"), + source: env.STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON ? "secret" : "none", + }; +} + +function hasConfigStore(env) { + return Boolean(configStore(env)); } -function adminLogins(env) { - return String(env.STRATEGY_SWITCH_ADMIN_LOGINS || "") - .split(",") - .map((login) => login.trim().toLowerCase()) - .filter(Boolean); +function configStore(env) { + const store = env.STRATEGY_SWITCH_CONFIG; + if (!store || typeof store.get !== "function" || typeof store.put !== "function") return null; + return store; } -function isAdminLogin(login, env) { - return adminLogins(env).includes(String(login || "").toLowerCase()); +async function readConfigJson(env, key) { + const store = configStore(env); + if (!store) return null; + const text = await store.get(key); + if (!text) return null; + try { + return JSON.parse(text); + } catch (error) { + throw new Error(`STRATEGY_SWITCH_CONFIG.${key} must be valid JSON`); + } } -function isAllowedLogin(login, env) { - const normalized = String(login || "").toLowerCase(); - return allowedLogins(env).includes(normalized) || isAdminLogin(normalized, env); +async function writeConfigJson(env, key, value) { + const store = configStore(env); + if (!store) throw new Error("STRATEGY_SWITCH_CONFIG KV binding is required"); + await store.put(key, JSON.stringify(value, null, 2)); +} + +async function loadAuditLog(env) { + if (!hasConfigStore(env)) return []; + const payload = await readConfigJson(env, AUDIT_LOG_KEY); + if (!Array.isArray(payload)) return []; + return payload + .filter((entry) => entry && !Array.isArray(entry) && typeof entry === "object") + .slice(0, AUDIT_LOG_LIMIT); +} + +async function appendAuditLog(env, entry) { + if (!hasConfigStore(env)) return; + let current = []; + try { + current = await loadAuditLog(env); + } catch (error) { + current = []; + } + await writeConfigJson(env, AUDIT_LOG_KEY, [entry, ...current].slice(0, AUDIT_LOG_LIMIT)); +} + +function accountCounts(accountOptions) { + const counts = {}; + for (const platform of SUPPORTED_PLATFORMS) { + counts[platform] = Array.isArray(accountOptions[platform]) ? accountOptions[platform].length : 0; + } + return counts; +} + +function uniqueStrings(items) { + const result = []; + const seen = new Set(); + for (const item of items) { + const text = String(item || "").trim().toLowerCase(); + if (!text || seen.has(text)) continue; + seen.add(text); + result.push(text); + } + return result; } async function makeSession(login, env) { @@ -447,8 +847,9 @@ async function readSession(request, env) { const session = JSON.parse(base64UrlDecode(payload)); if (!session.exp || session.exp < Math.floor(Date.now() / 1000)) return null; const login = String(session.login || "").toLowerCase(); - const admin = isAdminLogin(login, env); - return { login, allowed: admin || allowedLogins(env).includes(login), admin }; + const authConfig = await loadAuthConfig(env); + const admin = isAdminLogin(login, authConfig); + return { login, allowed: isAllowedLogin(login, authConfig), admin }; } async function hmac(value, secret) { diff --git a/web/strategy-switch-console/wrangler.toml.example b/web/strategy-switch-console/wrangler.toml.example index 669a34c..b315466 100644 --- a/web/strategy-switch-console/wrangler.toml.example +++ b/web/strategy-switch-console/wrangler.toml.example @@ -16,3 +16,9 @@ workers_dev = true # RUNTIME_SETTINGS_REPO = "QuantStrategyLab/QuantRuntimeSettings" # RUNTIME_SETTINGS_WORKFLOW = "manual-strategy-switch.yml" # RUNTIME_SETTINGS_REF = "main" + +# Bind this namespace to enable editable /admin login and account management. +# Without it, /admin is read-only and the Worker falls back to secrets. +# [[kv_namespaces]] +# binding = "STRATEGY_SWITCH_CONFIG" +# id = "replace-with-cloudflare-kv-namespace-id"