Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 62 additions & 15 deletions .github/workflows/sync-schwab-account-facts-binding.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions tests/account_facts_binding_workflow_validation.mjs
Original file line number Diff line number Diff line change
@@ -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`);
Loading