diff --git a/.github/workflows/sync-schwab-account-facts-binding.yml b/.github/workflows/sync-schwab-account-facts-binding.yml index bccaabd..8a008c7 100644 --- a/.github/workflows/sync-schwab-account-facts-binding.yml +++ b/.github/workflows/sync-schwab-account-facts-binding.yml @@ -1,7 +1,15 @@ -name: Sync Schwab account-facts binding +name: Sync account-facts binding on: workflow_dispatch: + inputs: + platform: + description: Protected binding to apply; IBKR only rotates an existing source binding. + type: choice + default: schwab + options: + - schwab + - ibkr permissions: contents: read @@ -22,6 +30,8 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || vars.CLOUDFLARE_ACCOUNT_ID }} STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID: ${{ secrets.STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID || vars.STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID }} SCHWAB_ACCOUNT_FACTS_BINDING_JSON: ${{ secrets.SCHWAB_ACCOUNT_FACTS_BINDING_JSON }} + IBKR_ACCOUNT_FACTS_SOURCE_ROTATION_JSON: ${{ secrets.IBKR_ACCOUNT_FACTS_SOURCE_ROTATION_JSON }} + BINDING_PLATFORM: ${{ inputs.platform || 'schwab' }} steps: - name: Checkout uses: actions/checkout@v6 @@ -50,7 +60,7 @@ jobs: chmod 600 "$HOME/.config/.wrangler/config/default.toml" fi - - name: Validate and sync exactly one Schwab binding + - name: Validate and sync exactly one protected binding working-directory: web/strategy-switch-console run: | set -euo pipefail @@ -60,8 +70,9 @@ jobs: cleanup() { rm -rf -- "$temp_dir"; } trap cleanup EXIT - if [ -z "${SCHWAB_ACCOUNT_FACTS_BINDING_JSON:-}" ]; then - echo "status=blocked reason=schwab_binding_secret_missing" + if { [ "$BINDING_PLATFORM" = "schwab" ] && [ -z "${SCHWAB_ACCOUNT_FACTS_BINDING_JSON:-}" ]; } \ + || { [ "$BINDING_PLATFORM" = "ibkr" ] && [ -z "${IBKR_ACCOUNT_FACTS_SOURCE_ROTATION_JSON:-}" ]; }; then + echo "status=blocked reason=binding_secret_missing" exit 1 fi @@ -90,11 +101,18 @@ jobs: process.exit(1); }; const tempDir = process.argv[2]; + const platform = process.env.BINDING_PLATFORM; + if (!["schwab", "ibkr"].includes(platform)) stop("binding_platform_invalid"); let proposedRaw; + let rotation; let optionsRaw; let existingRaw; try { - proposedRaw = JSON.parse(process.env.SCHWAB_ACCOUNT_FACTS_BINDING_JSON || ""); + if (platform === "ibkr") { + rotation = JSON.parse(process.env.IBKR_ACCOUNT_FACTS_SOURCE_ROTATION_JSON || ""); + } else { + proposedRaw = JSON.parse(process.env.SCHWAB_ACCOUNT_FACTS_BINDING_JSON || ""); + } optionsRaw = JSON.parse(readFileSync(`${tempDir}/account-options.json`, "utf8")); existingRaw = JSON.parse(readFileSync(`${tempDir}/account-facts-bindings.json`, "utf8")); } catch { @@ -104,33 +122,62 @@ jobs: let proposed; let existing; try { - proposed = normalizeAccountFactsBindings(proposedRaw); existing = normalizeAccountFactsBindings(existingRaw); + if (platform === "ibkr") { + if (!rotation || Array.isArray(rotation) + || Object.keys(rotation).sort().join(",") !== "next_source_binding_id,previous_source_binding_id,target_id" + || !/^[a-f0-9]{64}$/.test(rotation.previous_source_binding_id) + || !/^[a-f0-9]{64}$/.test(rotation.next_source_binding_id)) stop("rotation_config_invalid"); + const candidates = existing.bindings.filter((item) => item.platform === "ibkr" + && item.target_id === rotation.target_id); + if (candidates.length !== 1) stop("expected_existing_ibkr_binding"); + const index = existing.bindings.indexOf(candidates[0]); + proposedRaw = { + schema_version: existingRaw.schema_version, + bindings: [{ ...existingRaw.bindings[index], source_binding: { + ...existingRaw.bindings[index].source_binding, id: rotation.next_source_binding_id, + } }], + }; + } + proposed = normalizeAccountFactsBindings(proposedRaw); } catch { stop("binding_validation_failed"); } - if (proposed.bindings.length !== 1 || proposed.bindings[0].platform !== "schwab") { - stop("expected_single_schwab_binding"); + if (proposed.bindings.length !== 1 || proposed.bindings[0].platform !== platform) { + stop("expected_single_platform_binding"); } const binding = proposed.bindings[0]; - if (binding.account_scope !== "live") stop("binding_scope_mismatch"); - const schwabOptions = Array.isArray(optionsRaw?.schwab) ? optionsRaw.schwab : []; - const matches = schwabOptions.filter((option) => option?.key === binding.account_key); + if (platform === "schwab" && binding.account_scope !== "live") stop("binding_scope_mismatch"); + const options = Array.isArray(optionsRaw?.[platform]) ? optionsRaw[platform] : []; + const matches = options.filter((option) => option?.key === binding.account_key); if (matches.length !== 1 || !accountFactsOptionMatchesBinding(matches[0], binding)) { stop("live_account_option_mismatch"); } const sameKey = existing.bindings.find((item) => - item.platform === "schwab" && item.account_key === binding.account_key); - if (sameKey) { - if (JSON.stringify(sameKey) !== JSON.stringify(binding)) stop("existing_schwab_binding_conflict"); + item.platform === platform && item.account_key === binding.account_key); + if (sameKey && JSON.stringify(sameKey) === JSON.stringify(binding)) { process.stdout.write("status=unchanged\n"); process.exit(0); } + if (platform === "ibkr") { + const previousId = rotation.previous_source_binding_id; + if (!/^[a-f0-9]{64}$/.test(previousId) || !sameKey + || sameKey.source_binding.id !== previousId) stop("previous_binding_mismatch"); + const identity = (item) => ({ ...item, source_binding: { kind: item.source_binding.kind } }); + if (JSON.stringify(identity(sameKey)) !== JSON.stringify(identity(binding))) { + stop("binding_identity_change_forbidden"); + } + } else if (sameKey) { + stop("existing_schwab_binding_conflict"); + } const nextRaw = { schema_version: existingRaw.schema_version, - bindings: [...existingRaw.bindings, proposedRaw.bindings[0]], + bindings: platform === "ibkr" + ? existingRaw.bindings.map((item) => item.platform === platform + && item.account_key === binding.account_key ? proposedRaw.bindings[0] : item) + : [...existingRaw.bindings, proposedRaw.bindings[0]], }; try { // Revalidate the exact payload that will be written; preserve existing entries. diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 4ccd703..e16ab66 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -109,6 +109,7 @@ jobs: node --experimental-default-type=module tests/strategy_switch_worker_validation.mjs node tests/runtime_daily_worker_validation.mjs node tests/account_facts_validation.mjs + node tests/account_facts_binding_workflow_validation.mjs node --experimental-strip-types tests/account_history_presentation_validation.mjs node --experimental-strip-types tests/research_summary_worker_validation.mjs node tests/console_v2_worker_validation.mjs diff --git a/tests/account_facts_binding_workflow_validation.mjs b/tests/account_facts_binding_workflow_validation.mjs new file mode 100644 index 0000000..53010ca --- /dev/null +++ b/tests/account_facts_binding_workflow_validation.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const root = resolve(import.meta.dirname, ".."); +const workflow = readFileSync(join(root, ".github/workflows/sync-schwab-account-facts-binding.yml"), "utf8"); +const code = workflow.split("<<'NODE'", 2)[1].split("\n NODE", 1)[0] + .split("\n").map((line) => line.startsWith(" ") ? line.slice(10) : line).join("\n"); +const schema = "qsl_account_facts_bindings.v1"; +const primary = { + platform: "ibkr", account_key: "primary", account_scope: "live-primary", + target_name: "primary", service_name: "ibkr-placeholder-service", + deployment_selector: "ibkr-placeholder-service", account_selector: "U00000001", + target_id: "ibkr-primary", source_binding: { kind: "deployment_runtime_account", id: "a".repeat(64) }, +}; +const secondary = { ...primary, account_key: "secondary", target_name: "secondary", + account_scope: "live-secondary", account_selector: "U00000002", target_id: "ibkr-secondary", + source_binding: { kind: "deployment_runtime_account", id: "c".repeat(64) } }; +const rotation = { target_id: primary.target_id, + previous_source_binding_id: primary.source_binding.id, next_source_binding_id: "b".repeat(64) }; +const schwab = { ...primary, platform: "schwab", account_scope: "live", + account_selector: "schwab-placeholder", target_id: "schwab-primary", + broker_account_hash: "synthetic-placeholder-hash" }; + +const cases = [ + { name: "rotate", rows: [primary, secondary], rotation, expected: "prepared" }, + { name: "wrong_previous", rows: [primary, secondary], + rotation: { ...rotation, previous_source_binding_id: "d".repeat(64) }, expected: "blocked" }, + { name: "unknown_target", rows: [primary, secondary], + rotation: { ...rotation, target_id: "ibkr-new-placeholder" }, expected: "blocked" }, + { name: "identity_injection", rows: [primary, secondary], + rotation: { ...rotation, account_selector: "U00000003" }, expected: "blocked" }, + { name: "invalid_next", rows: [primary, secondary], + rotation: { ...rotation, next_source_binding_id: "invalid" }, expected: "blocked" }, + { name: "unchanged", rows: [{ ...primary, source_binding: { + ...primary.source_binding, id: rotation.next_source_binding_id } }, secondary], + rotation, expected: "unchanged" }, + { name: "schwab_append", platform: "schwab", rows: [primary, secondary], proposed: schwab, expected: "prepared" }, + { name: "schwab_unchanged", platform: "schwab", rows: [primary, schwab], proposed: schwab, expected: "unchanged" }, + { name: "schwab_conflict", platform: "schwab", rows: [primary, schwab], + proposed: { ...schwab, source_binding: { ...schwab.source_binding, id: "b".repeat(64) } }, expected: "blocked" }, +]; + +for (const test of cases) { + const temp = mkdtempSync(join(tmpdir(), "qsl-binding-synthetic-")); + try { + const platform = test.platform || "ibkr"; + const proposed = test.proposed || primary; + writeFileSync(join(temp, "account-options.json"), JSON.stringify({ [platform]: [{ ...proposed, key: proposed.account_key }] }), { mode: 0o600 }); + writeFileSync(join(temp, "account-facts-bindings.json"), JSON.stringify({ schema_version: schema, bindings: test.rows }), { mode: 0o600 }); + const child = spawnSync(process.execPath, ["--input-type=module", "-", temp], { + cwd: join(root, "web/strategy-switch-console"), input: code, encoding: "utf8", + env: { ...process.env, BINDING_PLATFORM: platform, + IBKR_ACCOUNT_FACTS_SOURCE_ROTATION_JSON: JSON.stringify(test.rotation || {}), + SCHWAB_ACCOUNT_FACTS_BINDING_JSON: JSON.stringify({ schema_version: schema, bindings: [proposed] }) }, + }); + assert.equal(child.status, test.expected === "blocked" ? 1 : 0, test.name); + assert.match(child.stdout, new RegExp(`status=${test.expected}`), test.name); + const output = join(temp, "next-bindings.json"); + assert.equal(existsSync(output), test.expected === "prepared", test.name); + if (test.name === "rotate") { + const next = JSON.parse(readFileSync(output, "utf8")); + assert.deepEqual(next.bindings, [{ ...primary, source_binding: { + ...primary.source_binding, id: rotation.next_source_binding_id } }, secondary]); + } + if (test.name === "schwab_append") { + assert.deepEqual(JSON.parse(readFileSync(output, "utf8")).bindings, [primary, secondary, schwab]); + } + } finally { + rmSync(temp, { recursive: true, force: true }); + } +} +console.log(`account-facts binding workflow validation: ${cases.length} cases passed`);