From 70b7a4d504575b3c337275884ab4e9585b7d2fe0 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:20:15 +0800 Subject: [PATCH] feat(console): record no-order risk profile preferences Co-Authored-By: Codex --- tests/strategy_switch_worker_validation.mjs | 100 ++++++ web/strategy-switch-console/README.md | 7 + web/strategy-switch-console/README.zh-CN.md | 7 + web/strategy-switch-console/worker.js | 323 +++++++++++++++++++- 4 files changed, 436 insertions(+), 1 deletion(-) diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 55e7031..19689a7 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -2180,6 +2180,106 @@ const recordedOwnerDecisionQueue = await worker.fetch( const recordedOwnerDecisionPayload = await recordedOwnerDecisionQueue.json(); assert.equal(recordedOwnerDecisionPayload.candidates[0].intent.decision, "keep_parked"); +const riskProfileStore = new Map(); +const riskProfileKv = { + async get(key) { return riskProfileStore.get(key) || null; }, + async put(key, value) { riskProfileStore.set(key, value); }, +}; +const riskProfileEnv = { + SESSION_SECRET: "risk-profile-session-value", + ALLOWED_GITHUB_LOGINS: "risk-admin,risk-reader", + STRATEGY_SWITCH_ADMIN_LOGINS: "risk-admin", + STRATEGY_SWITCH_CONFIG: riskProfileKv, + STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON: JSON.stringify({ + longbridge: [{ key: "sg", label: "Singapore", target_name: "sg" }], + schwab: [{ key: "default", label: "US", target_name: "default" }], + }), +}; +const riskAdminCookie = await __test.makeSession("risk-admin", [], riskProfileEnv); +const riskReaderCookie = await __test.makeSession("risk-reader", [], riskProfileEnv); +const riskAdminHeaders = { Cookie: `qsl_switch_session=${riskAdminCookie}` }; +const riskReaderHeaders = { Cookie: `qsl_switch_session=${riskReaderCookie}` }; + +const initialRiskProfiles = await worker.fetch( + new Request("https://switch.example/api/risk-profiles", { headers: riskAdminHeaders }), + riskProfileEnv, +); +assert.equal(initialRiskProfiles.status, 200); +assert.deepEqual((await initialRiskProfiles.json()).bindings, []); + +const riskProfileWrite = await worker.fetch( + new Request("https://switch.example/api/risk-profiles", { + method: "POST", + headers: { ...riskAdminHeaders, Origin: "https://switch.example", "Content-Type": "application/json" }, + body: JSON.stringify({ + bindings: [{ platform: "longbridge", target_name: "sg", risk_preference: "BALANCED_COMPOUNDING" }], + }), + }), + riskProfileEnv, +); +assert.equal(riskProfileWrite.status, 200); +const riskProfileWritePayload = await riskProfileWrite.json(); +assert.equal(riskProfileWritePayload.no_order, true); +assert.equal(riskProfileWritePayload.execution_authority_granted, false); +assert.equal(riskProfileWritePayload.bindings[0].scope_id, "longbridge--sg"); +assert.equal(riskProfileWritePayload.bindings[0].profile_selection.schema, "qsl.risk_profile_selection.v1"); +assert.equal(riskProfileWritePayload.bindings[0].profile_selection.profile_id, "balanced_compounding_v1"); +assert.equal(riskProfileWritePayload.bindings[0].profile_selection.risk_preference, "BALANCED_COMPOUNDING"); +assert.match(riskProfileWritePayload.bindings[0].profile_selection.selection_sha256, /^[0-9a-f]{64}$/); +assert.match(riskProfileWritePayload.bindings[0].binding_sha256, /^[0-9a-f]{64}$/); +assert.equal(riskProfileStore.has("risk_profile_bindings"), true); +assert.equal((await worker.fetch( + new Request("https://switch.example/api/risk-profiles", { headers: riskReaderHeaders }), + riskProfileEnv, +)).status, 403); + +const invalidRiskProfileTarget = await worker.fetch( + new Request("https://switch.example/api/risk-profiles", { + method: "POST", + headers: { ...riskAdminHeaders, Origin: "https://switch.example", "Content-Type": "application/json" }, + body: JSON.stringify({ + bindings: [{ platform: "longbridge", target_name: "missing", risk_preference: "BALANCED_COMPOUNDING" }], + }), + }), + riskProfileEnv, +); +assert.equal(invalidRiskProfileTarget.status, 400); +assert.match((await invalidRiskProfileTarget.json()).error, /not configured/); + +const crossOriginRiskProfileWrite = await worker.fetch( + new Request("https://switch.example/api/risk-profiles", { + method: "POST", + headers: { ...riskAdminHeaders, Origin: "https://evil.example", "Content-Type": "application/json" }, + body: JSON.stringify({ bindings: [] }), + }), + riskProfileEnv, +); +assert.equal(crossOriginRiskProfileWrite.status, 403); + +const tamperedRiskRegistry = JSON.parse(riskProfileStore.get("risk_profile_bindings")); +tamperedRiskRegistry.bindings[0].profile_selection.risk_preference = "GROWTH_COMPOUNDING"; +riskProfileStore.set("risk_profile_bindings", JSON.stringify(tamperedRiskRegistry)); +const tamperedRiskProfileRead = await worker.fetch( + new Request("https://switch.example/api/risk-profiles", { headers: riskAdminHeaders }), + riskProfileEnv, +); +assert.equal(tamperedRiskProfileRead.status, 409); +assert.equal((await tamperedRiskProfileRead.json()).reason, "risk_profile_bindings_invalid"); + +const directRiskProfileBindings = await __test.buildRiskProfileBindings( + { bindings: [{ platform: "schwab", target_name: "default", risk_preference: "CAPITAL_PRESERVATION" }] }, + JSON.parse(riskProfileEnv.STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON), + "risk-admin", +); +assert.equal(directRiskProfileBindings[0].profile_selection.profile_id, "capital_preservation_v1"); +assert.deepEqual( + await __test.normalizeRiskProfileBindingRegistry({ + schema_version: "qsl.risk_profile_binding_registry.v1", + bindings: directRiskProfileBindings, + }), + directRiskProfileBindings, +); + const staleDecision = await worker.fetch( new Request("https://switch.example/api/owner-decisions", { method: "POST", diff --git a/web/strategy-switch-console/README.md b/web/strategy-switch-console/README.md index 6da0e45..f70cb86 100644 --- a/web/strategy-switch-console/README.md +++ b/web/strategy-switch-console/README.md @@ -74,11 +74,18 @@ For editable admin settings, bind a Cloudflare KV namespace named `STRATEGY_SWIT auth_config account_options strategy_profiles +risk_profile_bindings audit_log ``` Without the KV binding, `/admin` is read-only and the Worker falls back to `ALLOWED_GITHUB_LOGINS`, `ALLOWED_GITHUB_ORGS`, `STRATEGY_SWITCH_ADMIN_LOGINS`, `STRATEGY_SWITCH_ADMIN_ORGS`, and `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`. +## Portfolio Risk Preference (non-executable intent) + +Administrators can select Capital Preservation, Balanced Compounding, or Growth Compounding for a configured platform target in `/admin`. Same-origin, admin-only `GET` / `POST /api/risk-profiles` stores a self-validating `qsl.risk_profile_binding.v1` record under `risk_profile_bindings`; its portable selection is exactly `qsl.risk_profile_selection.v1`, the contract used by the core risk composer. + +Every record is fixed to `no_order=true` and `execution_authority_granted=false`. It never enters `RUNTIME_TARGET_JSON`, changes strategy parameters or sizing, dispatches a workflow, accesses brokers or execution cloud resources, or enables paper, shadow, or live. A malformed KV record is unavailable rather than silently defaulted. A future independent, read-only control-plane adapter may consume only `profile_selection`, after separately validating observation evidence and all P4/P5/P6 gates. + ## Web Owner Decisions (P6 intent) Only a fresh P6 candidate with `owner_decision_required` and an `owner_live_decision` recommendation appears in the owner-decision area. Console administrators can record one of three choices: approve a limited-canary intent, keep the candidate parked, or retire it. diff --git a/web/strategy-switch-console/README.zh-CN.md b/web/strategy-switch-console/README.zh-CN.md index 2f71476..b109c86 100644 --- a/web/strategy-switch-console/README.zh-CN.md +++ b/web/strategy-switch-console/README.zh-CN.md @@ -81,6 +81,7 @@ STRATEGY_SWITCH_ADMIN_LOGINS=your-github-login auth_config account_options strategy_profiles +risk_profile_bindings audit_log strategy_health_snapshot control_plane_snapshot @@ -89,6 +90,12 @@ research_task_source: 没有绑定 KV 时,`/admin` 只读;Worker 会回退读取 `ALLOWED_GITHUB_LOGINS`、`ALLOWED_GITHUB_ORGS`、`STRATEGY_SWITCH_ADMIN_LOGINS`、`STRATEGY_SWITCH_ADMIN_ORGS` 和 `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`。 +## 组合风险偏好(非执行意图) + +管理员可在 `/admin` 为已配置的平台目标选择“保本优先 / 平衡复利 / 增长复利”。页面调用受同源校验和管理员权限保护的 `GET` / `POST /api/risk-profiles`,并只向 `risk_profile_bindings` 保存自校验的 `qsl.risk_profile_binding.v1` 记录;其中可移植的选择部分与核心风险合成器的 `qsl.risk_profile_selection.v1` 完全一致。 + +此记录固定为 `no_order=true` 和 `execution_authority_granted=false`:它不进入 `RUNTIME_TARGET_JSON`、不改策略参数或仓位、不调度 workflow、不读写券商或云执行资源,也不能启用 paper、shadow 或 live。KV 中记录损坏时接口会返回不可用,绝不会静默回退为默认风险偏好。未来独立的只读控制面适配器只能读取其中的 `profile_selection`,仍需另外验证观察证据和完整的 P4/P5/P6 门槛。 + ## 只读研究任务索引 `/api/internal/sync-research-task-source` 只接受 `qsl_research_task_source_snapshot.v1`,并要求独立的 `RESEARCH_TASK_SYNC_TOKEN`。每个任务均须是 SHA-256 自校验通过的 `qsl.research_task.v1`,固定为 `research_only=true`、`no_order=true`、`size_zero_required=true`、`p4_p5_p6_authorized=false`。已登录 allowlist 用户可从 `/api/research-tasks` 读取脱敏聚合结果。 diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index dfd8b8b..e9753c7 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -27,6 +27,19 @@ const SESSION_TTL_SECONDS = 8 * 60 * 60; const AUTH_CONFIG_KEY = "auth_config"; const ACCOUNT_OPTIONS_KEY = "account_options"; const STRATEGY_PROFILES_KEY = "strategy_profiles"; +// Risk preferences are a separate, low-frequency owner-intent record. They +// must never be merged into a strategy switch input, runtime target, or any +// execution credential/policy path. +const RISK_PROFILE_BINDINGS_KEY = "risk_profile_bindings"; +const RISK_PROFILE_BINDING_REGISTRY_SCHEMA_VERSION = "qsl.risk_profile_binding_registry.v1"; +const RISK_PROFILE_BINDING_SCHEMA_VERSION = "qsl.risk_profile_binding.v1"; +const RISK_PROFILE_SELECTION_SCHEMA_VERSION = "qsl.risk_profile_selection.v1"; +const RISK_PROFILE_IDS = { + CAPITAL_PRESERVATION: "capital_preservation_v1", + BALANCED_COMPOUNDING: "balanced_compounding_v1", + GROWTH_COMPOUNDING: "growth_compounding_v1", +}; +const RISK_PROFILE_PREFERENCES = Object.keys(RISK_PROFILE_IDS); const AUDIT_LOG_KEY = "audit_log"; const AUDIT_LOG_LIMIT = 50; const CURRENT_STRATEGIES_TIMEOUT_MS = 25000; @@ -214,6 +227,12 @@ export default { if (url.pathname === "/api/admin/config" && request.method === "POST") { return await saveAdminConfig(request, env); } + if (url.pathname === "/api/risk-profiles" && request.method === "GET") { + return await riskProfileBindingsResponse(request, env); + } + if (url.pathname === "/api/risk-profiles" && request.method === "POST") { + return await saveRiskProfileBindings(request, env); + } if (url.pathname === "/api/internal/sync-account-default" && request.method === "POST") { return await syncAccountDefaultResponse(request, env); } @@ -431,6 +450,70 @@ async function saveAdminConfig(request, env) { return json(await buildAdminState(session, env)); } +async function riskProfileBindingsResponse(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); + const bindingState = await loadRiskProfileBindings(env); + if (bindingState.error) { + return json({ + ok: false, + error: "risk profile bindings are unavailable", + reason: bindingState.error, + no_order: true, + execution_authority_granted: false, + }, 409); + } + return json({ + ok: true, + bindings: bindingState.bindings, + configured_targets: riskProfileBindingTargets((await loadAccountOptionsConfig(env)).options), + no_order: true, + execution_authority_granted: false, + }); +} + +async function saveRiskProfileBindings(request, env) { + requireSameOrigin(request, { requireOrigin: true }); + 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 risk profiles" }, 400); + } + + let raw; + try { + raw = await request.json(); + } catch { + return json({ ok: false, error: "request body must be valid JSON" }, 400); + } + const accountConfig = await loadAccountOptionsConfig(env); + let bindings; + try { + bindings = await buildRiskProfileBindings(raw, accountConfig.options, session.login); + } catch (error) { + return json({ ok: false, error: error.message || "risk profile bindings are invalid" }, 400); + } + await writeConfigJson(env, RISK_PROFILE_BINDINGS_KEY, { + schema_version: RISK_PROFILE_BINDING_REGISTRY_SCHEMA_VERSION, + bindings, + }); + await appendAuditLog(env, { + ts: new Date().toISOString(), + login: session.login, + action: "save_risk_profile_bindings", + binding_count: bindings.length, + }); + return json({ + ok: true, + bindings, + configured_targets: riskProfileBindingTargets(accountConfig.options), + no_order: true, + execution_authority_granted: false, + }); +} + async function requireAdminSession(request, env) { const session = await readSession(request, env); if (!session) return redirect("/login"); @@ -443,6 +526,7 @@ async function requireAdminSession(request, env) { async function buildAdminState(session, env) { const authConfig = await loadAuthConfig(env); const accountConfig = await loadAccountOptionsConfig(env); + const riskProfileBindingState = await loadRiskProfileBindings(env); return { ok: true, session: { login: session.login, admin: true }, @@ -450,6 +534,9 @@ async function buildAdminState(session, env) { authConfig, accountOptions: accountConfig.options || {}, accountOptionSource: accountConfig.source, + riskProfileBindings: riskProfileBindingState.bindings, + riskProfileBindingsError: riskProfileBindingState.error, + riskProfileBindingTargets: riskProfileBindingTargets(accountConfig.options), auditLog: await loadAuditLog(env), }; } @@ -470,6 +557,17 @@ async function renderAdminPage(state) { `${escapeHtml(entry.ts || "")}${escapeHtml(entry.login || "")}${escapeHtml(entry.action || "")}` )).join("") : `暂无记录 / No records`; + const profileByScope = new Map(state.riskProfileBindings.map((binding) => [binding.scope_id, binding])); + const riskProfileRows = state.riskProfileBindingTargets.length + ? state.riskProfileBindingTargets.map((target) => { + const selected = profileByScope.get(target.scope_id)?.profile_selection?.risk_preference || ""; + const option = (value, label) => ``; + return `${escapeHtml(target.platform)}${escapeHtml(target.target_name)}`; + }).join("") + : `暂无已配置目标 / No configured targets`; + const riskProfileNotice = state.riskProfileBindingsError + ? `风险偏好记录不可用:${escapeHtml(state.riskProfileBindingsError)}。请先修复 KV 中的记录。` + : "只保存组合风险偏好意图;不改策略、仓位、参数,不生成订单,也不授予实盘权限。"; return ` @@ -490,7 +588,7 @@ async function renderAdminPage(state) { } * { box-sizing: border-box; } body { margin: 0; min-height: 100svh; background: var(--bg); color: var(--ink); letter-spacing: 0; } - button, textarea { font: inherit; letter-spacing: 0; } + button, textarea, select { font: inherit; letter-spacing: 0; } .topbar { min-height: 68px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 28px; border-bottom: 1px solid var(--line); background: rgba(250, 251, 252, 0.94); @@ -528,6 +626,7 @@ async function renderAdminPage(state) { width: 100%; min-height: 118px; resize: vertical; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--ink); padding: 11px 12px; line-height: 1.45; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + select { min-height: 36px; width: 100%; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--ink); padding: 0 9px; } textarea.json { min-height: 320px; } .panel { padding: 18px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); } table { width: 100%; border-collapse: collapse; font-size: 13px; } @@ -602,6 +701,20 @@ async function renderAdminPage(state) { ${state.kvAvailable ? "" : "当前未绑定 STRATEGY_SWITCH_CONFIG KV,只能查看。"} +
+
+

组合风险偏好 / Portfolio Risk Preference

+

${riskProfileNotice}

+ + + ${riskProfileRows} +
PlatformTargetRisk preference
+
+
+ + +
+

账号数量 / Account Counts

@@ -620,6 +733,7 @@ async function renderAdminPage(state) { `; @@ -4321,6 +4460,183 @@ async function loadStrategyProfilesConfig(env) { return normalizeStrategyProfilesPayload(DEFAULT_STRATEGY_PROFILES, "DEFAULT_STRATEGY_PROFILES"); } +function riskProfileScopeId(platform, targetName) { + return `${platform}--${targetName}`; +} + +function riskProfileBindingTargets(accountOptions) { + const targets = []; + for (const platform of SUPPORTED_PLATFORMS) { + const options = Array.isArray(accountOptions?.[platform]) ? accountOptions[platform] : []; + for (const option of options) { + targets.push({ + scope_id: riskProfileScopeId(platform, option.target_name), + platform, + target_name: option.target_name, + }); + } + } + return targets.sort((left, right) => left.scope_id.localeCompare(right.scope_id)); +} + +function utcTimestampSeconds(now = new Date()) { + return now.toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +async function calculateRiskProfileSelectionSha256(payload) { + const material = { ...payload }; + delete material.selection_sha256; + const raw = new TextEncoder().encode(canonicalResearchTaskJson(material)); + const digest = await crypto.subtle.digest("SHA-256", raw); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function normalizeRiskProfileSelection(payload, fieldName) { + const value = assertExactFields(payload, ["schema", "profile_id", "risk_preference", "selection_sha256"], fieldName); + const riskPreference = cleanChoice(value.risk_preference, RISK_PROFILE_PREFERENCES, `${fieldName}.risk_preference`); + const normalized = { + schema: RISK_PROFILE_SELECTION_SCHEMA_VERSION, + profile_id: RISK_PROFILE_IDS[riskPreference], + risk_preference: riskPreference, + selection_sha256: normalizeResearchTaskDigest(value.selection_sha256, `${fieldName}.selection_sha256`), + }; + if (value.schema !== RISK_PROFILE_SELECTION_SCHEMA_VERSION) { + throw new Error(`${fieldName}.schema is unsupported`); + } + if (value.profile_id !== normalized.profile_id) { + throw new Error(`${fieldName}.profile_id does not match risk_preference`); + } + if (normalized.selection_sha256 !== await calculateRiskProfileSelectionSha256(normalized)) { + throw new Error(`${fieldName}.selection_sha256 mismatch`); + } + return normalized; +} + +async function calculateRiskProfileBindingSha256(payload) { + const material = { ...payload }; + delete material.binding_sha256; + const raw = new TextEncoder().encode(canonicalResearchTaskJson(material)); + const digest = await crypto.subtle.digest("SHA-256", raw); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function normalizeRiskProfileBinding(payload, fieldName) { + const value = assertExactFields(payload, [ + "schema_version", "scope_id", "platform", "target_name", "profile_selection", "updated_at", "updated_by", + "no_order", "execution_authority_granted", "binding_sha256", + ], fieldName); + const platform = cleanChoice(value.platform, SUPPORTED_PLATFORMS, `${fieldName}.platform`); + const targetName = cleanSlug(value.target_name, `${fieldName}.target_name`); + const normalized = { + schema_version: RISK_PROFILE_BINDING_SCHEMA_VERSION, + scope_id: riskProfileScopeId(platform, targetName), + platform, + target_name: targetName, + profile_selection: await normalizeRiskProfileSelection(value.profile_selection, `${fieldName}.profile_selection`), + updated_at: normalizeResearchTaskTimestamp(value.updated_at, `${fieldName}.updated_at`), + updated_by: cleanGithubLogin(value.updated_by, `${fieldName}.updated_by`), + no_order: value.no_order, + execution_authority_granted: value.execution_authority_granted, + binding_sha256: normalizeResearchTaskDigest(value.binding_sha256, `${fieldName}.binding_sha256`), + }; + if (value.schema_version !== RISK_PROFILE_BINDING_SCHEMA_VERSION) { + throw new Error(`${fieldName}.schema_version is unsupported`); + } + if (value.scope_id !== normalized.scope_id) { + throw new Error(`${fieldName}.scope_id does not match platform and target_name`); + } + if (normalized.no_order !== true || normalized.execution_authority_granted !== false) { + throw new Error(`${fieldName} must remain no-order and non-executable`); + } + if (normalized.binding_sha256 !== await calculateRiskProfileBindingSha256(normalized)) { + throw new Error(`${fieldName}.binding_sha256 mismatch`); + } + return normalized; +} + +async function normalizeRiskProfileBindingRegistry(payload, fieldName = RISK_PROFILE_BINDINGS_KEY) { + const value = assertExactFields(payload, ["schema_version", "bindings"], fieldName); + if (value.schema_version !== RISK_PROFILE_BINDING_REGISTRY_SCHEMA_VERSION) { + throw new Error(`${fieldName}.schema_version is unsupported`); + } + if (!Array.isArray(value.bindings) || value.bindings.length > SUPPORTED_PLATFORMS.length * 20) { + throw new Error(`${fieldName}.bindings must be a bounded array`); + } + const bindings = []; + const scopes = new Set(); + for (const [index, item] of value.bindings.entries()) { + const binding = await normalizeRiskProfileBinding(item, `${fieldName}.bindings[${index}]`); + if (scopes.has(binding.scope_id)) throw new Error(`${fieldName}.bindings contains duplicate scope_id`); + scopes.add(binding.scope_id); + bindings.push(binding); + } + return bindings.sort((left, right) => left.scope_id.localeCompare(right.scope_id)); +} + +async function buildRiskProfileBindings(payload, accountOptions, updatedBy) { + const value = assertExactFields(payload, ["bindings"], "risk profile bindings request"); + if (!Array.isArray(value.bindings) || value.bindings.length > SUPPORTED_PLATFORMS.length * 20) { + throw new Error("risk profile bindings request.bindings must be a bounded array"); + } + const bindings = []; + const scopes = new Set(); + const updatedAt = utcTimestampSeconds(); + for (const [index, item] of value.bindings.entries()) { + const itemValue = assertExactFields(item, ["platform", "target_name", "risk_preference"], `risk profile bindings request.bindings[${index}]`); + const platform = cleanChoice(itemValue.platform, SUPPORTED_PLATFORMS, `risk profile bindings request.bindings[${index}].platform`); + const targetName = cleanSlug(itemValue.target_name, `risk profile bindings request.bindings[${index}].target_name`); + if (!riskProfileBindingTargets(accountOptions).some((target) => target.platform === platform && target.target_name === targetName)) { + throw new Error(`risk profile binding target is not configured: ${platform}/${targetName}`); + } + const scopeId = riskProfileScopeId(platform, targetName); + if (scopes.has(scopeId)) throw new Error("risk profile bindings request contains duplicate target"); + scopes.add(scopeId); + const riskPreference = cleanChoice( + itemValue.risk_preference, + RISK_PROFILE_PREFERENCES, + `risk profile bindings request.bindings[${index}].risk_preference`, + ); + const profileSelection = { + schema: RISK_PROFILE_SELECTION_SCHEMA_VERSION, + profile_id: RISK_PROFILE_IDS[riskPreference], + risk_preference: riskPreference, + selection_sha256: "", + }; + profileSelection.selection_sha256 = await calculateRiskProfileSelectionSha256(profileSelection); + const binding = { + schema_version: RISK_PROFILE_BINDING_SCHEMA_VERSION, + scope_id: scopeId, + platform, + target_name: targetName, + profile_selection: profileSelection, + updated_at: updatedAt, + updated_by: cleanGithubLogin(updatedBy, "risk profile binding.updated_by"), + no_order: true, + execution_authority_granted: false, + binding_sha256: "", + }; + binding.binding_sha256 = await calculateRiskProfileBindingSha256(binding); + bindings.push(await normalizeRiskProfileBinding(binding, `risk profile bindings request.bindings[${index}]`)); + } + return bindings.sort((left, right) => left.scope_id.localeCompare(right.scope_id)); +} + +async function loadRiskProfileBindings(env) { + if (!hasConfigStore(env)) return { bindings: [], error: null }; + try { + const stored = await readConfigJson(env, RISK_PROFILE_BINDINGS_KEY); + if (!stored) return { bindings: [], error: null }; + return { + bindings: await normalizeRiskProfileBindingRegistry(stored), + error: null, + }; + } catch { + // Do not silently default malformed owner intent. It has no runtime + // authority, but the next control-plane adapter must see the failure. + return { bindings: [], error: "risk_profile_bindings_invalid" }; + } +} + function hasConfigStore(env) { return Boolean(configStore(env)); } @@ -4543,6 +4859,11 @@ export const __test = { normalizeSwitchInputs, normalizeAccountOptionsPayload, normalizeStrategyProfilesPayload, + calculateRiskProfileSelectionSha256, + calculateRiskProfileBindingSha256, + normalizeRiskProfileBindingRegistry, + buildRiskProfileBindings, + riskProfileBindingTargets, platformRepositories, requireSameOrigin, responseHeaders,